mcpField
P
payment-integration

payment-integration

github.com/travisjneuman開発106136 回インストール今日更新
リポジトリ

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-D ではなく、PCI SAQ-A(最も簡素な準拠レベル)に準拠できます。
  2. Webhook を信頼できる唯一の情報源とする - クライアント側の決済確認を決して信用しないでください。クライアント側で PaymentIntent が成功しても、webhook ハンドラーが payment_intent.succeeded を確認するまでは何の意味もありません。システムは webhook イベントを中心に構築してください。
  3. あらゆる処理で冪等性を確保する - Webhook は複数回配信されることがあります。すべての webhook ハンドラーは冪等でなければなりません。重複排除キーとして Stripe のイベント ID を使用してください。
  4. 失敗に適切に対処する - 決済は、残高不足、カードの有効期限切れ、不正利用の検知など、さまざまな理由で失敗します。サブスクリプションのライフサイクルに、再試行フロー、督促メール、段階的な機能縮小を組み込んでください。
  5. Stripe のテストモードでテストする - サブスクリプションのライフサイクルをテストする際は、テスト API キー、テスト用カード番号、テストクロックを使用してください。実際の決済手段でテストしてはいけません。

主なパターン

パターン 1:サブスクリプション向け Stripe Checkout

使用する場面: 決済フォーム、クーポンコード、税額計算を含むチェックアウト UI 全体を 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 セキュア認証、税額計算、プロモーションコード、ローカライズに対応します。独自のチェックアウトフォームを構築するには数百時間かかり、その後も継続的に PCI 準拠の負担が生じます。カスタム UI を構築する強い理由がない限り、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 を少なくとも 1 回配信するため、重複する可能性があります。冪等性チェック(処理済みイベント 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 はホスト型のソリューションで、プラン変更、日割り計算、理由の記録を伴う解約、決済手段の更新、請求書の履歴に対応します。これらをすべて、カスタム UI を構築せずに実現できます。


パターン 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 32203D 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 エンドポイントを登録している
  • ローカルでの Webhook テスト用に Stripe CLI をインストールしている(stripe listen --forward-to)

関連リソース

  • スキル: authentication-patterns(請求に使用するユーザー ID)、email-systems(督促メール)
  • スキル: monitoring-observability(支払い失敗のアラート)
  • ルール: docs/reference/stacks/fullstack-nextjs-nestjs.md(API ルートのパターン)

コメント