mcpField
W
web-payments

web-payments

github.com/alinaqi開発711294 回インストール2 週間前に更新
リポジトリ

web-payments について

Web アプリに Stripe 決済を組み込む開発者向けのスキルです。一回限りの決済、サブスクリプション、Webhook、請求ポータル、決済の安全対策を扱います。

主な機能

  1. 01ホスト型・埋め込み型 Checkout フロー
  2. 02Payment Element の統合ガイド
  3. 03Webhook の署名検証
  4. 04サブスクリプションと請求ポータルのパターン
  5. 05Stripe CLI による決済テスト

活用シーン

  1. 01Web アプリへの一回限りの決済追加
  2. 02定期購読とトライアルの設定
  3. 03決済・サブスクリプションイベントの処理
  4. 04ローカル開発での Webhook 処理テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/alinaqi/maggy --skill web-payments

インストール後、Claude Code で/web-paymentsを入力して読み込まれたか確認します。

詳細

ウェブ決済スキル(Stripe)

ウェブアプリケーションに Stripe 決済(単発決済、サブスクリプション、チェックアウトフロー)を組み込むためのガイドです。

情報源: Stripe Checkout | Payment Element のベストプラクティス | Stripe 連携を堅牢に構築する | サブスクリプション


セットアップ

1. Stripe アカウントを作成する
  1. https://dashboard.stripe.com/register にアクセスします
  2. 事業者確認を完了します
  3. https://dashboard.stripe.com/apikeys から API キーを取得します
2. 環境変数
# .env
STRIPE_SECRET_KEY=sk_test_xxx          # Server-side only
STRIPE_PUBLISHABLE_KEY=pk_test_xxx     # Client-side safe
STRIPE_WEBHOOK_SECRET=whsec_xxx        # For webhook verification

# Production
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_PUBLISHABLE_KEY=pk_live_xxx
3. SDK をインストールする
# Node.js
npm install stripe @stripe/stripe-js

# Python
pip install stripe

連携方法

方法最適な用途複雑さ
Checkout(ホスト型)すばやく設定したい場合、Stripe がホストするページ低
Checkout(埋め込み型)カスタムサイト、埋め込みフォーム低
Payment Element高度なカスタマイズ、複雑なフロー中
カスタムフォーム完全な制御(まれ)高

推奨: まず Checkout を使い、必要に応じて Payment Element に移行します。


Stripe Checkout(推奨)

サーバー:Checkout セッションを作成する
Node.js / Next.js
// app/api/checkout/route.ts (Next.js App Router)
import Stripe from "stripe";
import { NextResponse } from "next/server";

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

export async function POST(request: Request) {
  const { priceId, mode = "payment" } = await request.json();

  try {
    const session = await stripe.checkout.sessions.create({
      mode: mode as "payment" | "subscription",
      payment_method_types: ["card"],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_URL}/canceled`,
      // Optional: Link to existing customer
      // customer: customerId,
      // Optional: Collect shipping
      // shipping_address_collection: { allowed_countries: ["US", "CA"] },
      // Optional: Add metadata for tracking
      metadata: {
        userId: "user_123",
        source: "pricing_page",
      },
    });

    return NextResponse.json({ sessionId: session.id, url: session.url });
  } catch (error) {
    console.error("Stripe error:", error);
    return NextResponse.json({ error: "Failed to create session" }, { status: 500 });
  }
}
Python / FastAPI
# app/api/checkout.py
import stripe
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
router = APIRouter()

class CheckoutRequest(BaseModel):
    price_id: str
    mode: str = "payment"  # or "subscription"

@router.post("/api/checkout")
async def create_checkout_session(request: CheckoutRequest):
    try:
        session = stripe.checkout.Session.create(
            mode=request.mode,
            payment_method_types=["card"],
            line_items=[{
                "price": request.price_id,
                "quantity": 1,
            }],
            success_url=f"{os.environ['APP_URL']}/success?session_id={{CHECKOUT_SESSION_ID}}",
            cancel_url=f"{os.environ['APP_URL']}/canceled",
            metadata={
                "user_id": "user_123",
            },
        )
        return {"session_id": session.id, "url": session.url}
    except stripe.error.StripeError as e:
        raise HTTPException(status_code=400, detail=str(e))
クライアント:Checkout にリダイレクトする
// components/CheckoutButton.tsx
"use client";

import { loadStripe } from "@stripe/stripe-js";

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

export function CheckoutButton({ priceId }: { priceId: string }) {
  const handleCheckout = async () => {
    const response = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });

    const { url } = await response.json();

    // Redirect to Stripe Checkout
    window.location.href = url;
  };

  return (
    <button onClick={handleCheckout}>
      Subscribe Now
    </button>
  );
}

Checkout の埋め込み

ユーザーをサイト内にとどめる場合:

// components/EmbeddedCheckout.tsx
"use client";

import { useEffect, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
  EmbeddedCheckoutProvider,
  EmbeddedCheckout,
} from "@stripe/react-stripe-js";

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

export function EmbeddedCheckoutForm({ priceId }: { priceId: string }) {
  const [clientSecret, setClientSecret] = useState("");

  useEffect(() => {
    fetch("/api/checkout/embedded", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, [priceId]);

  if (!clientSecret) return <div>Loading...</div>;

  return (
    <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
      <EmbeddedCheckout />
    </EmbeddedCheckoutProvider>
  );
}

埋め込み用のサーバーエンドポイント:

// app/api/checkout/embedded/route.ts
export async function POST(request: Request) {
  const { priceId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    ui_mode: "embedded",
    return_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  });

  return NextResponse.json({ clientSecret: session.client_secret });
}

Webhook(重要)

クライアント側のデータは決して信用しないでください。Webhook を使って必ず決済を検証します。

Webhook エンドポイント
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";

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

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

  let event: Stripe.Event;

  // Verify webhook signature
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed");
    return new Response("Invalid signature", { status: 400 });
  }

  // Handle events
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionUpdate(subscription);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCanceled(subscription);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Return 200 quickly - process async if needed
  return new Response("OK", { status: 200 });
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;

  // Update your database
  await db.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      subscriptionStatus: "active",
    },
  });
}
Python Webhook
# app/api/webhooks.py
import stripe
from fastapi import APIRouter, Request, HTTPException

router = APIRouter()

@router.post("/api/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Handle events
    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        await handle_checkout_complete(session)
    elif event["type"] == "customer.subscription.deleted":
        subscription = event["data"]["object"]
        await handle_subscription_canceled(subscription)

    return {"status": "success"}
主な Webhook イベント
イベント発生時アクション
checkout.session.completed決済成功時アクセス権を付与する
customer.subscription.created新しいサブスクリプションの作成時サブスクリプション ID を保存する
customer.subscription.updatedプラン変更時DB のプランを更新する
customer.subscription.deletedキャンセル時アクセス権を取り消す
invoice.payment_failed決済失敗時ユーザーに通知し、再試行する
invoice.paid更新成功時アクセス権を延長する

商品と価格

ダッシュボードから作成する(推奨)
  1. https://dashboard.stripe.com/products にアクセスします
  2. 名前と説明を設定して商品を作成します
  3. 価格(単発または継続)を追加します
  4. 価格 ID(price_xxx)をコピーします
API から作成する
// One-time product
const product = await stripe.products.create({
  name: "Pro Plan",
  description: "Full access to all features",
});

const price = await stripe.prices.create({
  product: product.id,
  unit_amount: 2999, // $29.99 in cents
  currency: "usd",
});

// Subscription product
const subscriptionPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 999, // $9.99/month
  currency: "usd",
  recurring: {
    interval: "month",
  },
});

カスタマーポータル

ユーザーがサブスクリプションを管理できるようにします:

// app/api/portal/route.ts
export async function POST(request: Request) {
  const { customerId } = await request.json();

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

  return NextResponse.json({ url: session.url });
}

ポータルの設定先:https://dashboard.stripe.com/settings/billing/portal


サブスクリプション

トライアル付きのサブスクリプションを作成する
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: {
    trial_period_days: 14,
    // Cancel if no payment method after trial
    trial_settings: {
      end_behavior: { missing_payment_method: "cancel" },
    },
  },
  success_url: successUrl,
  cancel_url: cancelUrl,
});
サブスクリプションのステータスを確認する
// lib/subscription.ts
export async function getSubscriptionStatus(customerId: string) {
  const subscriptions = await stripe.subscriptions.list({
    customer: customerId,
    status: "all",
    limit: 1,
  });

  if (subscriptions.data.length === 0) {
    return { status: "none", plan: null };
  }

  const subscription = subscriptions.data[0];
  return {
    status: subscription.status,
    plan: subscription.items.data[0].price.id,
    currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    cancelAtPeriodEnd: subscription.cancel_at_period_end,
  };
}

テスト

テストカード
カード番号シナリオ
4242424242424242成功
4000000000000002拒否
40000025000031553D Secure が必要
4000000000009995残高不足
Webhook 用の Stripe CLI
# Install CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted

プロジェクト構成

project/
├── app/
│   ├── api/
│   │   ├── checkout/
│   │   │   └── route.ts          # Create checkout session
│   │   ├── portal/
│   │   │   └── route.ts          # Customer portal
│   │   └── webhooks/
│   │       └── stripe/
│   │           └── route.ts      # Webhook handler
│   ├── pricing/
│   │   └── page.tsx              # Pricing page
│   ├── success/
│   │   └── page.tsx              # Post-checkout success
│   └── settings/
│       └── page.tsx              # Manage subscription
├── lib/
│   ├── stripe.ts                 # Stripe client
│   └── subscription.ts           # Subscription helpers
└── .env.local

セキュリティのベストプラクティス

必ず守るルール
  1. シークレットはサーバー側だけで扱う - STRIPE_SECRET_KEY を決して公開しないでください
  2. Webhook は必ず検証する - 処理前に署名を確認します
  3. 冪等性 - Webhook イベント ID を保存し、重複をスキップします
  4. メタデータを使う - デバッグ用にユーザー ID やソースを追跡します
  5. すべての状態に対応する - 成功、失敗、保留中、キャンセル
冪等性のある Webhook ハンドラー
const processedEvents = new Set<string>(); // Use Redis in production

export async function POST(request: Request) {
  // ... verify signature ...

  // Skip duplicate events
  if (processedEvents.has(event.id)) {
    return new Response("Already processed", { status: 200 });
  }
  processedEvents.add(event.id);

  // Process event...
}
金額の扱い
// Always use cents (smallest currency unit)
const priceInCents = 2999; // $29.99

// Helper functions
const toCents = (dollars: number) => Math.round(dollars * 100);
const toDollars = (cents: number) => cents / 100;

// Display
const displayPrice = (cents: number) =>
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(toDollars(cents));

よくあるパターン

料金ページ
// app/pricing/page.tsx
const plans = [
  {
    name: "Starter",
    price: "$9/mo",
    priceId: "price_starter_monthly",
    features: ["Feature 1", "Feature 2"],
  },
  {
    name: "Pro",
    price: "$29/mo",
    priceId: "price_pro_monthly",
    features: ["Everything in Starter", "Feature 3", "Feature 4"],
    popular: true,
  },
];

export default function PricingPage() {
  return (
    <div className="grid md:grid-cols-2 gap-8">
      {plans.map((plan) => (
        <div key={plan.name} className={plan.popular ? "border-blue-500" : ""}>
          <h3>{plan.name}</h3>
          <p>{plan.price}</p>
          <ul>
            {plan.features.map((f) => <li key={f}>{f}</li>)}
          </ul>
          <CheckoutButton priceId={plan.priceId} />
        </div>
      ))}
    </div>
  );
}
サブスクリプションによるルートの保護
// middleware.ts
import { getSubscriptionStatus } from "@/lib/subscription";

export async function middleware(request: NextRequest) {
  const session = await getSession();

  if (request.nextUrl.pathname.startsWith("/pro")) {
    const { status } = await getSubscriptionStatus(session.stripeCustomerId);

    if (status !== "active" && status !== "trialing") {
      return NextResponse.redirect(new URL("/pricing", request.url));
    }
  }
}

アンチパターン

  • API キーのハードコーディング - 環境変数を使用します
  • クライアント側での支払い作成 - 必ずサーバー側で PaymentIntent/Session を作成します
  • Webhook 検証の省略 - 必ず署名を検証します
  • 重複した Webhook の処理 - 冪等性を実装します
  • 浮動小数点数による通貨計算 - 整数(セント)を使用します
  • クライアントデータの信頼 - すべてサーバー側で検証します
  • 支払い失敗の無視 - invoice.payment_failed を処理します
  • エラーハンドリングの欠如 - Stripe エラーを捕捉して処理します

クイックリファレンス

# Install
npm install stripe @stripe/stripe-js @stripe/react-stripe-js

# Stripe CLI
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed

# Test mode prefix
sk_test_xxx  # Secret key
pk_test_xxx  # Publishable key

# Live mode prefix
sk_live_xxx
pk_live_xxx
主なエンドポイント
エンドポイント用途
POST /api/checkoutチェックアウトセッションを作成
POST /api/portal顧客向け請求ポータル
POST /api/webhooks/stripeStripe イベントを処理
環境変数
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

コメント