個人開発者のための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を選択する理由
- 学習コストの最小化: 個人開発者が素早く決済機能を実装できる
- セキュリティ: Stripeが全てのセキュリティを担保
- 保守性: アップデートやセキュリティパッチをStripeが自動適用
- 十分な機能: 多くのビジネスケースでCheckoutで十分
- 後から移行可能: 必要に応じて他の方法に移行できる
移行パス
初期は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処理とデータベース設計について説明します。
Discussion