mcpField
P
payment-integration

payment-integration

github.com/mgd34msu开发654 次安装10 周前更新
代码仓库

关于 payment-integration

payment-integration 是帮助开发者在应用中实现支付处理的技能,涵盖服务商选择、结账、订阅、Webhook、安全与测试。它提供 Stripe、LemonSqueezy 和 Paddle 的实施模式及验证脚本。

主要功能

  1. 01比较支付服务商并记录选择
  2. 02实现一次性支付与结账流程
  3. 03构建订阅管理流程
  4. 04验证并处理 Webhook
  5. 05检查密钥与 PCI 安全实践
  6. 06运行支付集成验证脚本

适用场景

  1. 01为应用添加一次性付款
  2. 02搭建订阅结算与管理
  3. 03测试并排查 Webhook 处理
  4. 04迁移支付服务商并检查安全

安装

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

$npx skills add https://github.com/mgd34msu/goodvibes-plugin --skill payment-integration

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

附带文件references/payment-patterns.mdscripts/validate-payments.sh

详细介绍

资源

scripts/
  validate-payments.sh
references/
  payment-patterns.md

支付集成实现

本技能将指导你在应用中实现支付处理,从选择服务提供商到处理 Webhook。它利用 GoodVibes 精准工具,帮助你构建安全、可用于生产环境的支付集成。

何时使用此技能

在以下情况下使用此技能:

  • 设置支付处理(一次性或定期支付)
  • 实现结账流程和支付表单
  • 处理订阅计费和管理
  • 安全地处理支付 Webhook
  • 在开发环境中测试支付流程
  • 确保符合 PCI 标准并满足安全要求
  • 在不同支付服务提供商之间迁移

工作流

按照以下顺序集成支付:

1. 发现现有支付基础设施

实现支付功能前,先了解当前状态:

precision_grep:
  queries:
    - id: payment-libs
      pattern: "stripe|lemonsqueezy|paddle"
      glob: "package.json"
    - id: webhook-routes
      pattern: "(webhook|payment|checkout)"
      glob: "src/**/*.{ts,js,tsx,jsx}"
  output:
    format: files_only

检查以下内容:

  • 现有支付库
  • Webhook 端点
  • API 密钥的环境变量
  • 与支付相关的数据库模型

检查项目记忆中的支付决策:

precision_read:
  files:
    - path: ".goodvibes/memory/decisions.md"
      extract: content
  output:
    format: minimal

搜索支付服务提供商的选择、结账流程模式和订阅模型。

2. 选择支付服务提供商

根据需求进行选择:

在以下情况下使用 Stripe:

  • 需要最大的灵活性和控制力
  • 支持复杂的订阅模式
  • 需要高级功能(开票、税务、欺诈检测)
  • 构建自定义支付流程
  • 需要详尽的 API 文档

在以下情况下使用 LemonSqueezy:

  • 销售数字产品或 SaaS
  • 希望由名义商家处理(负责税务、增值税和合规)
  • 需要简单的订阅管理
  • 希望尽量减少合规负担
  • 在全球范围内运营并自动处理税务

在以下情况下使用 Paddle:

  • 销售软件或 SaaS 产品
  • 需要名义商家服务
  • 希望管理订阅和许可证
  • 希望使用收入追回功能
  • 在 B2B/企业市场运营

记录决策:

precision_write:
  files:
    - path: ".goodvibes/memory/decisions.md"
      mode: overwrite
      content: |
        ## Payment Provider Selection
        
        **Decision**: Using [Provider] for payment processing
        **Rationale**: [Why this provider fits requirements]
        **Date**: YYYY-MM-DD
        
        Key factors:
        - [Factor 1]
        - [Factor 2]
        - [Factor 3]
3. 设置和安装

安装相应的 SDK:

Stripe:

precision_exec:
  commands:
    - cmd: "npm install stripe @stripe/stripe-js"
    # @types/stripe is not needed - Stripe ships with built-in types
  verbosity: minimal

LemonSqueezy:

precision_exec:
  commands:
    - cmd: "npm install @lemonsqueezy/lemonsqueezy.js"
  verbosity: minimal

Paddle:

precision_exec:
  commands:
    - cmd: "npm install @paddle/paddle-node-sdk"
  verbosity: minimal

配置环境变量:

创建 .env.example 模板:

precision_write:
  files:
    - path: ".env.example"
      mode: overwrite
      content: |
        # Payment Provider Configuration
        
        # Stripe (if using Stripe)
        STRIPE_SECRET_KEY=sk_test_...
        STRIPE_PUBLISHABLE_KEY=pk_test_...
        STRIPE_WEBHOOK_SECRET=whsec_...
        
        # LemonSqueezy (if using LemonSqueezy)
        LEMONSQUEEZY_API_KEY=...
        LEMONSQUEEZY_STORE_ID=...
        LEMONSQUEEZY_WEBHOOK_SECRET=...
        
        # Paddle (if using Paddle)
        PADDLE_API_KEY=...
        PADDLE_WEBHOOK_SECRET=...
        PADDLE_ENVIRONMENT=sandbox

确认机密信息已被 Git 忽略:

precision_grep:
  queries:
    - id: check-gitignore
      pattern: "^\\.env$"
      path: ".gitignore"
  output:
    format: files_only

如果未找到,请将 .env 添加到 .gitignore。

4. 实现结账流程
一次性付款(Stripe)

创建结账会话端点:

precision_write:
  files:
    - path: "src/app/api/checkout/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import Stripe from 'stripe';
        import { getServerSession } from 'next-auth';
        import { authOptions } from '@/lib/auth';
        import { z } from 'zod';
        
        const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
        if (!stripeSecretKey) {
          throw new Error('STRIPE_SECRET_KEY is required');
        }
        
        const stripe = new Stripe(stripeSecretKey, {
          apiVersion: '2024-11-20.acacia',
        });
        
        export async function POST(request: NextRequest) {
          try {
            // Check authentication
            const authSession = await getServerSession();
            if (!authSession?.user) {
              return NextResponse.json(
                { error: 'Unauthorized' },
                { status: 401 }
              );
            }
            
            // Validate input with Zod
            const schema = z.object({
              priceId: z.string().min(1),
              successUrl: z.string().url(),
              cancelUrl: z.string().url(),
            });
            
            const body = await request.json();
            const result = schema.safeParse(body);
            
            if (!result.success) {
              return NextResponse.json(
                { error: 'Invalid input', details: result.error.flatten() },
                { status: 400 }
              );
            }
            
            const { priceId, successUrl, cancelUrl } = result.data;
            
            // Validate URLs against allowed origins
            const appUrl = process.env.NEXT_PUBLIC_APP_URL;
            if (!appUrl) throw new Error('NEXT_PUBLIC_APP_URL is required');
            const allowedOrigins = [appUrl];
            const successOrigin = new URL(successUrl).origin;
            const cancelOrigin = new URL(cancelUrl).origin;
            
            if (!allowedOrigins.includes(successOrigin) || !allowedOrigins.includes(cancelOrigin)) {
              return NextResponse.json(
                { error: 'Invalid redirect URLs' },
                { status: 400 }
              );
            }
            
            const stripeSession = await stripe.checkout.sessions.create({
              mode: 'payment',
              line_items: [
                {
                  price: priceId,
                  quantity: 1,
                },
              ],
              success_url: successUrl,
              cancel_url: cancelUrl,
              metadata: {
                userId: authSession.user.id,
              },
            });
            
            return NextResponse.json({ sessionId: stripeSession.id });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Checkout error:', message);
            return NextResponse.json(
              { error: 'Failed to create checkout session' },
              { status: 500 }
            );
          }
        }
一次性付款(LemonSqueezy)
precision_write:
  files:
    - path: "src/app/api/checkout/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import { lemonSqueezySetup, createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
        import { getServerSession } from 'next-auth';
        import { authOptions } from '@/lib/auth';
        
        const lemonSqueezyApiKey = process.env.LEMONSQUEEZY_API_KEY;
        if (!lemonSqueezyApiKey) {
          throw new Error('LEMONSQUEEZY_API_KEY is required');
        }
        lemonSqueezySetup({ apiKey: lemonSqueezyApiKey });
        
        export async function POST(request: NextRequest) {
          try {
            const authSession = await getServerSession(authOptions);
            if (!authSession?.user) {
              return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
            }
            
            const { variantId, email } = await request.json();
            
            const lemonSqueezyStoreId = process.env.LEMONSQUEEZY_STORE_ID;
            if (!lemonSqueezyStoreId) {
              throw new Error('LEMONSQUEEZY_STORE_ID is required');
            }
            
            const { data, error } = await createCheckout(
              lemonSqueezyStoreId,
              variantId,
              {
                checkoutData: {
                  email,
                  custom: {
                    user_id: authSession.user.id,
                  },
                },
              }
            );
            
            if (error) {
              throw new Error(error.message);
            }
            
            return NextResponse.json({ checkoutUrl: data.attributes.url });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Checkout error:', message);
            return NextResponse.json(
              { error: 'Failed to create checkout' },
              { status: 500 }
            );
          }
        }
5. 订阅计费
创建订阅计划(Stripe)
precision_write:
  files:
    - path: "src/lib/stripe/plans.ts"
      content: |
        export const SUBSCRIPTION_PLANS = {
          starter: {
            name: 'Starter',
            priceId: (() => {
              const priceId = process.env.STRIPE_PRICE_STARTER;
              if (!priceId) throw new Error('STRIPE_PRICE_STARTER is required');
              return priceId;
            })(),
            price: 9,
            interval: 'month' as const,
            features: ['Feature 1', 'Feature 2'],
          },
          pro: {
            name: 'Pro',
            priceId: (() => {
              const priceId = process.env.STRIPE_PRICE_PRO;
              if (!priceId) throw new Error('STRIPE_PRICE_PRO is required');
              return priceId;
            })(),
            price: 29,
            interval: 'month' as const,
            features: ['All Starter features', 'Feature 3', 'Feature 4'],
          },
        } as const;
        
        export type PlanId = keyof typeof SUBSCRIPTION_PLANS;
订阅结账
precision_write:
  files:
    - path: "src/app/api/subscribe/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import Stripe from 'stripe';
        import { z } from 'zod';
        import { SUBSCRIPTION_PLANS } from '@/lib/stripe/plans';
        import { getServerSession } from 'next-auth';
        import { authOptions } from '@/lib/auth';
        
        // Validate required environment variables
        const secretKey = process.env.STRIPE_SECRET_KEY;
        if (!secretKey) throw new Error('STRIPE_SECRET_KEY is required');
        const stripe = new Stripe(secretKey, {
          apiVersion: '2024-11-20.acacia',
        });
        
        const subscribeSchema = z.object({
          planId: z.string().min(1),
          customerId: z.string().min(1),
          successUrl: z.string().url(),
          cancelUrl: z.string().url(),
        });
        
        export async function POST(request: NextRequest) {
          try {
            const authSession = await getServerSession(authOptions);
            if (!authSession?.user) {
              return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
            }
            
            const body = await request.json();
            const result = subscribeSchema.safeParse(body);
            
            if (!result.success) {
              return NextResponse.json(
                { error: result.error.flatten() },
                { status: 400 }
              );
            }
            
            const { planId, customerId, successUrl, cancelUrl } = result.data;
            
            const plan = SUBSCRIPTION_PLANS[planId as keyof typeof SUBSCRIPTION_PLANS];
            if (!plan) {
              return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
            }
            
            const stripeSession = await stripe.checkout.sessions.create({
              mode: 'subscription',
              customer: customerId,
              line_items: [
                {
                  price: plan.priceId,
                  quantity: 1,
                },
              ],
              success_url: successUrl,
              cancel_url: cancelUrl,
              subscription_data: {
                trial_period_days: 14,
              },
            });
            
            return NextResponse.json({ sessionId: stripeSession.id });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Subscription error:', message);
            return NextResponse.json(
              { error: 'Failed to create subscription' },
              { status: 500 }
            );
          }
        }
管理订阅
precision_write:
  files:
    - path: "src/app/api/subscription/manage/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import Stripe from 'stripe';
        import { getServerSession } from 'next-auth';
        import { authOptions } from '@/lib/auth';
        
        const secretKey = process.env.STRIPE_SECRET_KEY;
        if (!secretKey) throw new Error('STRIPE_SECRET_KEY is required');
        const stripe = new Stripe(secretKey, {
          apiVersion: '2024-11-20.acacia',
        });
        
        // Cancel subscription
        export async function DELETE(request: NextRequest) {
          try {
            const authSession = await getServerSession(authOptions);
            if (!authSession?.user) {
              return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
            }
            
            const { subscriptionId } = await request.json();
            
            const subscription = await stripe.subscriptions.update(subscriptionId, {
              cancel_at_period_end: true,
            });
            
            return NextResponse.json({ subscription });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Cancel error:', message);
            return NextResponse.json(
              { error: 'Failed to cancel subscription' },
              { status: 500 }
            );
          }
        }
        
        // Update subscription
        export async function PATCH(request: NextRequest) {
          try {
            const authSession = await getServerSession(authOptions);
            if (!authSession?.user) {
              return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
            }
            
            const { subscriptionId, newPriceId } = await request.json();
            
            const subscription = await stripe.subscriptions.retrieve(subscriptionId);
            
            const updated = await stripe.subscriptions.update(subscriptionId, {
              items: [
                {
                  id: subscription.items.data[0].id,
                  price: newPriceId,
                },
              ],
              proration_behavior: 'create_prorations',
            });
            
            return NextResponse.json({ subscription: updated });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Update error:', message);
            return NextResponse.json(
              { error: 'Failed to update subscription' },
              { status: 500 }
            );
          }
        }
6. 处理 Webhook

Webhook 对支付处理至关重要。它会将支付事件通知你的应用。

Stripe Webhook 处理程序
precision_write:
  files:
    - path: "src/app/api/webhooks/stripe/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import Stripe from 'stripe';
        import { headers } from 'next/headers';
        import { db } from '@/lib/db';
        
        const secretKey = process.env.STRIPE_SECRET_KEY;
        if (!secretKey) throw new Error('STRIPE_SECRET_KEY is required');
        const stripe = new Stripe(secretKey, {
          apiVersion: '2024-11-20.acacia',
        });
        
        const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
        if (!webhookSecret) {
          throw new Error('STRIPE_WEBHOOK_SECRET is required');
        }
        
        export async function POST(request: NextRequest) {
          const body = await request.text();
          const headersList = await headers();
          const signature = headersList.get('stripe-signature');
          
          if (!signature) {
            return NextResponse.json(
              { error: 'Missing stripe-signature header' },
              { status: 400 }
            );
          }
          
          let event: Stripe.Event;
          
          try {
            // Verify webhook signature
            event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Webhook signature verification failed:', message);
            return NextResponse.json(
              { error: 'Invalid signature' },
              { status: 400 }
            );
          }
          
          // Handle the event
          try {
            switch (event.type) {
              case 'checkout.session.completed': {
                const session = event.data.object as Stripe.Checkout.Session;
                await handleCheckoutComplete(session);
                break;
              }
                
              case 'customer.subscription.created': {
                const subscription = event.data.object as Stripe.Subscription;
                await handleSubscriptionCreated(subscription);
                break;
              }
                
              case 'customer.subscription.updated':
                await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
                break;
                
              case 'customer.subscription.deleted':
                await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
                break;
                
              case 'invoice.payment_succeeded':
                await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
                break;
                
              case 'invoice.payment_failed':
                await handlePaymentFailed(event.data.object as Stripe.Invoice);
                break;
                
              default:
                // Replace with structured logger in production
                console.log(`Unhandled event type: ${event.type}`);
            }
            
            return NextResponse.json({ received: true });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Webhook handler error:', message);
            return NextResponse.json(
              { error: 'Webhook handler failed' },
              { status: 500 }
            );
          }
        }
        
        async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
          // Update database with payment details
          await db.payment.create({
            data: {
              id: session.payment_intent as string,
              userId: session.metadata?.userId,
              amount: session.amount_total ?? 0,
              currency: session.currency ?? 'usd',
              status: 'succeeded',
            },
          });
          // Grant access to product/service based on metadata
        }
        
        async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
          // Create subscription record in database
          await db.subscription.create({
            data: {
              id: subscription.id,
              userId: subscription.metadata?.userId,
              status: subscription.status,
              currentPeriodEnd: new Date(subscription.current_period_end * 1000),
            },
          });
        }
        
        async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
          // Update subscription status in database
          await db.subscription.update({
            where: { id: subscription.id },
            data: {
              status: subscription.status,
              currentPeriodEnd: new Date(subscription.current_period_end * 1000),
            },
          });
        }
        
        async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
          // Mark subscription as canceled in database
          await db.subscription.update({
            where: { id: subscription.id },
            data: {
              status: 'canceled',
              canceledAt: new Date(),
            },
          });
          // Revoke access at period end
        }
        
        async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
          // Record successful payment
          await db.payment.create({
            data: {
              id: invoice.payment_intent as string,
              userId: invoice.subscription_details?.metadata?.userId,
              amount: invoice.amount_paid,
              currency: invoice.currency,
              status: 'succeeded',
            },
          });
        }
        
        async function handlePaymentFailed(invoice: Stripe.Invoice) {
          // Notify user of failed payment
          await db.payment.create({
            data: {
              id: invoice.payment_intent as string,
              userId: invoice.subscription_details?.metadata?.userId,
              amount: invoice.amount_due,
              currency: invoice.currency,
              status: 'failed',
            },
          });
          // Send notification email
        }
LemonSqueezy Webhook 处理程序
precision_write:
  files:
    - path: "src/app/api/webhooks/lemonsqueezy/route.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        import crypto from 'crypto';
        import { db } from '@/lib/db';
        
        const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
        if (!webhookSecret) {
          throw new Error('LEMONSQUEEZY_WEBHOOK_SECRET is required');
        }
        
        export async function POST(request: NextRequest) {
          const body = await request.text();
          const signature = request.headers.get('x-signature');
          
          if (!signature) {
            return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
          }
          
          // Verify webhook signature
          const hmac = crypto.createHmac('sha256', webhookSecret);
          const digest = hmac.update(body).digest('hex');
          
          const signatureBuffer = Buffer.from(signature, 'utf8');
          const digestBuffer = Buffer.from(digest, 'utf8');
          if (signatureBuffer.length !== digestBuffer.length || !crypto.timingSafeEqual(signatureBuffer, digestBuffer)) {
            console.error('Webhook signature verification failed');
            return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
          }
          
          const event = JSON.parse(body);
          
          try {
            switch (event.meta.event_name) {
              case 'order_created':
                await handleOrderCreated(event.data);
                break;
                
              case 'subscription_created':
                await handleSubscriptionCreated(event.data);
                break;
                
              case 'subscription_updated':
                await handleSubscriptionUpdated(event.data);
                break;
                
              case 'subscription_cancelled':
                await handleSubscriptionCancelled(event.data);
                break;
                
              case 'subscription_payment_success':
                await handlePaymentSuccess(event.data);
                break;
                
              default:
                // Replace with structured logger in production
                console.log(`Unhandled event: ${event.meta.event_name}`);
            }
            
            return NextResponse.json({ received: true });
          } catch (error: unknown) {
            const message = error instanceof Error ? error.message : 'Unknown error';
            console.error('Webhook handler error:', message);
            return NextResponse.json(
              { error: 'Webhook handler failed' },
              { status: 500 }
            );
          }
        }
        
        interface LemonSqueezyWebhookData {
          id: string;
          attributes: Record<string, unknown>;
        }
        
        async function handleOrderCreated(data: LemonSqueezyWebhookData) {
          await db.order.create({
            data: {
              id: data.id,
              status: 'completed',
              attributes: data.attributes,
            },
          });
        }
        
        async function handleSubscriptionCreated(data: LemonSqueezyWebhookData) {
          await db.subscription.create({
            data: {
              id: data.id,
              status: 'active',
              userId: data.attributes.user_id as string,
              attributes: data.attributes,
            },
          });
        }
        
        async function handleSubscriptionUpdated(data: LemonSqueezyWebhookData) {
          await db.subscription.update({
            where: { id: data.id },
            data: {
              status: data.attributes.status as string,
              attributes: data.attributes,
            },
          });
        }
        
        async function handleSubscriptionCancelled(data: LemonSqueezyWebhookData) {
          await db.subscription.update({
            where: { id: data.id },
            data: {
              status: 'cancelled',
              cancelledAt: new Date(),
            },
          });
        }
        
        async function handlePaymentSuccess(data: LemonSqueezyWebhookData) {
          await db.payment.create({
            data: {
              id: data.id,
              status: 'succeeded',
              amount: data.attributes.total as number,
              attributes: data.attributes,
            },
          });
        }
Webhook 幂等性

始终实现幂等性,以处理重复投递的 Webhook:

precision_write:
  files:
    - path: "src/lib/webhooks/idempotency.ts"
      content: |
        import { db } from '@/lib/db';
        
        export async function isProcessed(eventId: string): Promise<boolean> {
          const existing = await db.webhookEvent.findUnique({
            where: { id: eventId },
          });
          
          return !!existing;
        }
        
        export async function markProcessed(eventId: string): Promise<void> {
          await db.webhookEvent.create({
            data: {
              id: eventId,
              processedAt: new Date(),
            },
          });
        }
        
        export async function withIdempotency<T>(
          eventId: string,
          handler: () => Promise<T>
        ): Promise<T | null> {
          // Use database transaction to prevent TOCTOU race condition
          return db.$transaction(async (tx) => {
            const existing = await tx.webhookEvent.findUnique({
              where: { id: eventId },
            });
            
            if (existing) {
              // Replace with structured logger in production
              console.log(`Event ${eventId} already processed, skipping`);
              return null;
            }
            
            const result = await handler();
            
            await tx.webhookEvent.create({
              data: {
                id: eventId,
                processedAt: new Date(),
              },
            });
            
            return result;
          });
        }

更新 Webhook 处理程序以使用幂等性:

// In Stripe webhook
const eventId = event.id;
await withIdempotency(eventId, async () => {
  // Handle event
});
7. 测试支付流程
使用测试模式密钥

在开发环境中始终使用测试模式密钥:

precision_grep:
  queries:
    - id: check-test-keys
      pattern: "(sk_test|pk_test)"
      path: ".env"
  output:
    format: files_only
使用 Stripe CLI 测试 Webhook

安装并配置 Stripe CLI:

precision_exec:
  commands:
    - cmd: "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
  background: true
  verbosity: minimal
测试卡号

在开发文档中记录测试卡号:

precision_write:
  files:
    - path: "docs/testing-payments.md"
      content: |
        # Payment Testing Guide
        
        ## Test Cards (Stripe)
        
        - Success: 4242 4242 4242 4242
        - Decline: 4000 0000 0000 0002
        - Requires authentication: 4000 0025 0000 3155
        - Insufficient funds: 4000 0000 0000 9995
        
        Use any future expiry date and any 3-digit CVC.
        
        ## Test Webhooks
        
        1. Start webhook listener: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`
        2. Trigger events: `stripe trigger payment_intent.succeeded`
        
        ## LemonSqueezy Testing
        
        Use sandbox mode in .env:
        LEMONSQUEEZY_API_KEY=... (sandbox key)
自动化测试

为 Webhook 处理程序创建测试:

precision_write:
  files:
    - path: "src/__tests__/webhooks/stripe.test.ts"
      content: |
        import { POST } from '@/app/api/webhooks/stripe/route';
        import { NextRequest } from 'next/server';
        import Stripe from 'stripe';
        
        describe('Stripe Webhook Handler', () => {
          it('should verify webhook signature', async () => {
            const mockEvent = {
              id: 'evt_test_123',
              type: 'checkout.session.completed',
              data: { object: {} },
            };
            
            const request = new NextRequest('http://localhost:3000/api/webhooks/stripe', {
              method: 'POST',
              body: JSON.stringify(mockEvent),
              headers: {
                'stripe-signature': 'invalid_signature',
              },
            });
            
            const response = await POST(request);
            expect(response.status).toBe(400);
          });
          
          it('should handle checkout.session.completed', async () => {
            // Test event handling
          });
        });
8. 安全与 PCI 合规
切勿直接处理银行卡数据

使用托管结账页面或 Elements,避免银行卡数据进入你的服务器:

precision_grep:
  queries:
    - id: check-card-handling
      pattern: "(card_number|cvv|cvc|card.*exp)"
      glob: "src/**/*.{ts,js,tsx,jsx}"
  output:
    format: files_only

如果发现任何匹配项,请重构代码以使用服务提供商的托管解决方案。

强制使用 HTTPS
precision_write:
  files:
    - path: "src/middleware.ts"
      content: |
        import { NextRequest, NextResponse } from 'next/server';
        
        export function middleware(request: NextRequest) {
          // Enforce HTTPS in production
          if (
            process.env.NODE_ENV === 'production' &&
            request.headers.get('x-forwarded-proto') !== 'https'
          ) {
            return NextResponse.redirect(
              `https://${request.headers.get('host')}${request.nextUrl.pathname}`,
              301
            );
          }
          
          return NextResponse.next();
        }
        
        export const config = {
          matcher: '/api/checkout/:path*',
        };
保护 API 密钥

确认 API 密钥未硬编码:

precision_grep:
  queries:
    - id: hardcoded-keys
      pattern: "(sk_live|sk_test)_[a-zA-Z0-9]{24,}"
      glob: "src/**/*.{ts,js,tsx,jsx}"
  output:
    format: files_only

如果发现硬编码的密钥,请立即将其移至环境变量。

内容安全策略

为支付页面添加 CSP 标头:

precision_write:
  files:
    - path: "next.config.js"
      mode: overwrite
      content: |
        module.exports = {
          async headers() {
            return [
              {
                source: '/(checkout|subscribe)/:path*',
                headers: [
                  {
                    key: 'Content-Security-Policy',
                    value: [
                      "default-src 'self'",
                      "script-src 'self' 'unsafe-inline' https://js.stripe.com",
                      "frame-src https://js.stripe.com",
                      "connect-src 'self' https://api.stripe.com",
                    ].join('; '),
                  },
                ],
              },
            ];
          },
        };
9. 验证

运行验证脚本,确保遵循最佳实践:

precision_exec:
  commands:
    - cmd: "bash plugins/goodvibes/skills/outcome/payment-integration/scripts/validate-payments.sh ."
      expect:
        exit_code: 0
  verbosity: standard

脚本会检查:

  • 是否安装支付库
  • API 密钥是否位于 .env.example(而非 .env)
  • Webhook 端点是否存在
  • 是否验证 Webhook 签名
  • 是否存在硬编码的机密信息
  • 是否强制使用 HTTPS
  • 是否处理错误
10. 支付数据库架构

在数据库中添加支付跟踪:

model Customer {
  id              String   @id @default(cuid())
  userId          String   @unique
  stripeCustomerId String?  @unique
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
  
  subscriptions   Subscription[]
  payments        Payment[]
  
  @@index([stripeCustomerId])
}

model Subscription {
  id                   String   @id @default(cuid())
  customerId           String
  customer             Customer @relation(fields: [customerId], references: [id])
  stripeSubscriptionId String   @unique
  stripePriceId        String
  status               String
  currentPeriodEnd     DateTime
  cancelAtPeriodEnd    Boolean  @default(false)
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt
  
  @@index([customerId])
  @@index([status])
}

model Payment {
  id              String   @id @default(cuid())
  customerId      String
  customer        Customer @relation(fields: [customerId], references: [id])
  stripePaymentId String   @unique
  amount          Int
  currency        String
  status          String
  createdAt       DateTime @default(now())
  
  @@index([customerId])
  @@index([status])
}

model WebhookEvent {
  id          String   @id
  processedAt DateTime @default(now())
  
  @@index([processedAt])
}

常见模式

模式:计划变更时按比例计费

升级或降级订阅时:

const updated = await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, price: newPriceId }],
  proration_behavior: 'create_prorations', // Credit/charge immediately
});
模式:试用期

提供免费试用:

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  subscription_data: {
    trial_period_days: 14,
    trial_settings: {
      end_behavior: {
        missing_payment_method: 'cancel', // Cancel if no payment method
      },
    },
  },
});
模式:Webhook 重试逻辑

支付提供商会重试失败的 webhook。务必:

  • 尽快返回 200 OK(必要时异步处理)
  • 实现幂等性
  • 记录所有 webhook 事件
模式:失败支付恢复

处理催款(失败支付恢复):

case 'invoice.payment_failed':
  const invoice = event.data.object as Stripe.Invoice;
  if (invoice.attempt_count >= 3) {
    // Cancel subscription after 3 failures
    await stripe.subscriptions.cancel(invoice.subscription as string);
    await notifyCustomer(invoice.customer as string, 'payment_failed_final');
  } else {
    await notifyCustomer(invoice.customer as string, 'payment_failed_retry');
  }
  break;

应避免的反模式

不要:存储银行卡详情

切勿存储银行卡号、CVV 或完整的银行卡数据。请使用令牌化。

不要:跳过 Webhook 验证

务必验证 webhook 签名。未经验证的 webhook 会带来安全风险。

不要:使用客户端定价

切勿信任客户端传来的价格。务必在服务端查询价格:

// BAD
const { amount } = await request.json();
await stripe.charges.create({ amount });

// GOOD
const { priceId } = await request.json();
const price = await stripe.prices.retrieve(priceId);
await stripe.charges.create({ amount: price.unit_amount });
不要:忽略失败的 Webhook

监控 webhook 投递情况并调查失败原因。设置告警。

不要:硬编码货币

如果面向国际客户,请支持多种货币:

const session = await stripe.checkout.sessions.create({
  currency: userCountry === 'US' ? 'usd' : 'eur',
});

参考资料

参见 references/payment-patterns.md,其中包括:

  • 提供商对比表
  • 完整的 webhook 事件参考
  • 订阅生命周期状态机
  • 测试策略
  • 安全检查清单

后续步骤

完成支付集成后:

  1. 全面测试 - 使用测试模式并模拟故障
  2. 监控 webhook - 设置日志记录和告警
  3. 记录流程 - 为支付流程编写内部文档
  4. 规划扩展 - 考虑速率限制和并发 webhook
  5. 实现分析 - 跟踪转化率和流失率
  6. 审查合规性 - 如适用,确保符合 PCI-DSS
  7. 设置监控 - 跟踪支付成功率和错误

评论

支持 Markdown