👨‍💻

個人開発者のためのStripe超入門②基本実装-単発決済(前半)

に公開

第2章(前半) 基本実装 - 単発決済

概要

React+Supabaseを使ったStripe決済の基本実装です。単発決済の実装方法を解説します。
Stripe Checkoutを使用すると、決済ページを自前で用意することなく実装できて便利です。
(後から独自決済ページへの移行も可能)

※コードは一例のため、実装時にはご確認ください

Stripe決済実装方法の比較

Stripe決済には複数の実装アプローチがあります。それぞれの特徴を理解して、プロジェクトに最適な方法を選択することが重要です。

実装方法の比較表

方法 開発工数 カスタマイズ性 セキュリティ 保守性 適用ケース
Stripe Checkout ★☆☆ ★☆☆ ★★★ ★★★ 最小限実装・MVP
Payment Element ★★☆ ★★☆ ★★☆ ★★☆ 中程度カスタマイズ
Payment Intents API ★★★ ★★★ ★☆☆ ★☆☆ 完全カスタマイズ

各方法の詳細

Stripe Checkout(本ガイドで採用)

メリット:

  • 実装が超簡単(数行のコード)
  • Stripeがホストする安全な決済ページ
  • PCI DSS準拠が自動
  • 多言語・多通貨対応
  • モバイル最適化済み

デメリット:

  • デザインのカスタマイズが限定的
  • 外部ページへのリダイレクトが発生
  • ブランド体験の一貫性が損なわれる場合がある

Payment Element

メリット:

  • 自サイト内で決済完結
  • ある程度のデザインカスタマイズ可能
  • UXの一貫性を保てる
  • 複数の決済方法に対応

デメリット:

  • Checkoutより実装が複雑
  • フロントエンドでのセキュリティ配慮が必要
  • エラーハンドリングの実装が必要

Payment Intents API

メリット:

  • 完全なデザイン自由度
  • 独自の決済フローを構築可能
  • 高度な決済シナリオに対応

デメリット:

  • 実装が最も複雑
  • セキュリティリスクが高い
  • PCI DSS準拠の負担
  • 保守コストが高い

本ガイドでCheckoutを選択する理由

  1. 学習コストの最小化: 個人開発者が素早く決済機能を実装できる
  2. セキュリティ: Stripeが全てのセキュリティを担保
  3. 保守性: アップデートやセキュリティパッチをStripeが自動適用
  4. 十分な機能: 多くのビジネスケースでCheckoutで十分
  5. 後から移行可能: 必要に応じて他の方法に移行できる

移行パス

初期はCheckoutで開始し、ビジネスの成長とともに段階的に移行するのがおすすめです。

データベース設計

Stripe決済の実装前に、決済情報を管理するデータベーステーブルを設計します。

テーブル構成の概要

テーブル作成SQL

-- supabase/migrations/20250623000000_init_stripe_tables.sql

-- 顧客テーブル
CREATE TABLE customers (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES auth.users(id),
  stripe_customer_id TEXT UNIQUE NOT NULL,
  email TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 決済履歴テーブル
CREATE TABLE payments (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID NOT NULL REFERENCES customers(id),
  stripe_payment_intent_id TEXT UNIQUE NOT NULL,
  amount INTEGER NOT NULL,
  currency TEXT NOT NULL DEFAULT 'jpy',
  status TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- サブスクリプションテーブル
CREATE TABLE subscriptions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID NOT NULL REFERENCES customers(id),
  stripe_subscription_id TEXT UNIQUE NOT NULL,
  stripe_price_id TEXT NOT NULL,
  status TEXT NOT NULL,
  current_period_start TIMESTAMP WITH TIME ZONE,
  current_period_end TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Webhookイベントログテーブル(重複処理防止)
CREATE TABLE webhook_events (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  stripe_event_id TEXT UNIQUE NOT NULL,
  event_type TEXT NOT NULL,
  processed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- インデックス作成
CREATE INDEX idx_customers_user_id ON customers(user_id);
CREATE INDEX idx_customers_stripe_id ON customers(stripe_customer_id);
CREATE INDEX idx_payments_customer_id ON payments(customer_id);
CREATE INDEX idx_subscriptions_customer_id ON subscriptions(customer_id);

各テーブルの役割

  • customers: Supabase AuthのユーザーとStripe顧客を紐付け
  • payments: 単発決済の履歴を記録
  • subscriptions: サブスクリプションの状態を管理
  • webhook_events: Webhookの重複処理を防止

プロジェクト構成例(React/Vite/Supabase)

基本的なファイル構成例です。
フロントエンドはReact/Vite、バックエンドはSupabase Edge Functionsを使用します。
ミニマムに実装できます。

src/
├── components/
│   ├── checkout/
│   │   ├── CheckoutButton.tsx      // 決済ボタン
│   │   ├── CheckoutForm.tsx        // 決済フォーム
│   │   └── SubscriptionPlans.tsx   // サブスクプラン選択
├── lib/
│   ├── stripe.ts                   // Stripe設定
│   ├── supabase.ts                 // Supabase設定
│   └── types.ts                    // 型定義
├── pages/
│   ├── checkout/
│   │   ├── Success.tsx             // 決済成功ページ
│   │   └── Cancel.tsx              // 決済キャンセルページ
supabase/
├── functions/
│   ├── stripe-create-checkout-session/
│   │   └── index.ts
│   ├── stripe-create-subscription/
│   │   └── index.ts
│   └── stripe-webhook/
│       └── index.ts                // Webhookハンドラー
├── migrations/
│   └── 20250623000000_init_stripe_tables.sql  // データベース初期設定

基本設定

React/SupabaseでStripe決済を使うために必要な初期設定です。フロントエンドではStripeオブジェクト、バックエンドではSupabaseクライアントを設定します。

Stripe設定ファイル

フロントエンド側で使用するStripeオブジェクトの設定です。公開可能キー(pk_test_...)を使用してStripeオブジェクトを初期化します。

// lib/stripe.ts
import { loadStripe } from '@stripe/stripe-js';

// クライアント側のStripe公開可能キー
export const stripePromise = loadStripe(
  import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!
);

Supabase設定ファイル

データベース接続とEdge Functions用のSupabase Javascriptクライアント設定です。フロントエンドからSupabaseのAPIを利用するためのものです。

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL!;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

単発決済の実装

一回限りの決済処理を実装します。ユーザーがボタンをクリックするとStripe Checkoutページに遷移し、決済が完了する流れです。

1. 決済ボタンコンポーネント

ユーザーが押すボタンのコンポーネントです。ボタンがクリックされるとSupabase Edge Functionを呼び出し、決済セッションを作成してStripe Checkoutにリダイレクトします。

// components/checkout/CheckoutButton.tsx
import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { supabase } from '../../lib/supabase';

interface CheckoutButtonProps {
  priceId: string;
  productName: string;
}

export const CheckoutButton: React.FC<CheckoutButtonProps> = ({
  priceId,
  productName,
}) => {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    
    try {
      // 認証トークン取得
      const { data: { session } } = await supabase.auth.getSession();
      
      // Supabase Edge Function呼び出し
      const { data, error } = await supabase.functions.invoke('stripe-create-checkout-session', {
        body: {
          priceId,
          mode: 'payment', // 単発決済
        },
        headers: {
          Authorization: `Bearer ${session?.access_token}`,
        },
      });

      if (error) throw error;

      // Stripe Checkoutへリダイレクト
      const stripe = await loadStripe(
        import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!
      );
      
      await stripe?.redirectToCheckout({ sessionId: data.sessionId });
    } catch (error) {
      console.error('決済エラー:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button 
      onClick={handleCheckout}
      disabled={loading}
      className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
    >
      {loading ? '処理中...' : `${productName}を購入`}
    </button>
  );
};

2. 決済セッション作成Edge Function

バックエンド側で動作するEdge Functionです。フロントエンドからのリクエストを受けて、Stripeの決済セッションを作成します。認証チェックも行い、セキュリティを確保します。
決済成功後はsuccess_url、キャンセル時はcancel_urlに遷移します。

// supabase/functions/stripe-create-checkout-session/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14.21.0?target=deno';

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  try {
    // JWTトークンから認証情報取得
    const authHeader = req.headers.get('Authorization');
    if (!authHeader) {
      return new Response('Unauthorized', { status: 401 });
    }

    const supabaseAdmin = createClient(
      Deno.env.get('SUPABASE_URL') ?? '',
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
    );

    const token = authHeader.replace('Bearer ', '');
    const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(token);
    
    if (authError || !user) {
      return new Response('Unauthorized', { status: 401 });
    }

    const { priceId, mode = 'payment' } = await req.json();

    const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') || '', {
      apiVersion: '2023-10-16',
    });

    // Stripe顧客作成(単発決済でも顧客管理は必須)
    let customer;
    const { data: existingCustomer } = await supabaseAdmin
      .from('customers')
      .select('stripe_customer_id')
      .eq('user_id', user.id)
      .single();

    if (existingCustomer?.stripe_customer_id) {
      customer = { id: existingCustomer.stripe_customer_id };
    } else {
      customer = await stripe.customers.create({
        email: user.email!,
        metadata: {
          userId: user.id,
        },
      });

      // 顧客情報をデータベースに保存
      await supabaseAdmin
        .from('customers')
        .insert({
          user_id: user.id,
          stripe_customer_id: customer.id,
          email: user.email!,
        });
    }

    const session = await stripe.checkout.sessions.create({
      customer: customer.id,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode,
      success_url: `${req.headers.get('origin')}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${req.headers.get('origin')}/checkout/cancel`,
    });

    return new Response(
      JSON.stringify({ sessionId: session.id }),
      { 
        headers: { 
          ...corsHeaders,
          'Content-Type': 'application/json' 
        } 
      }
    );
  } catch (error) {
    console.error('セッション作成エラー:', error);
    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { 
        status: 500,
        headers: { 
          ...corsHeaders,
          'Content-Type': 'application/json' 
        } 
      }
    );
  }
});

決済成功時の処理

決済が成功すると、ユーザーは決済成功ページにリダイレクトされます。
同時にStripeからwebhook経由でSupabaseエッジファンクションの決済成功処理が呼び出されます。
この実装については次でくわしく取り上げます。

まとめ

React+Supabase Edge Functionsでの単発決済の基本実装を解説しました。顧客情報の管理も含めた基本的な流れです。

次章(後半)では、Webhook処理とデータベース設計について説明します。


前:第1章 Stripe決済の全体像

Discussion