はじめに
1時間の音声を15秒で文字起こしし自然な会話形式に自動変換する「インタビューAI」を個人開発中。
現在、開発環境での開発をほぼ終え、本番環境への移行中。
フロントエンドはReact、バックエンドはNode.js、データベースはMongoDBで開発。
今回は、Stripeのテスト環境を使用して、月額課金プランの変更の決済を実装し、動作確認する。
Stripeのテスト環境設定
テスト用APIキーの取得
Stripeのテスト環境を使用する場合、**公開可能キー(Publishable Key)と秘密キー(Secret Key)**を使用する
Stripeにアカウント作成して、ダッシュボードに移動したら、ナビゲーションのテスト環境というスイッチ部分をONにして、テスト環境での動作確認にうつる。
すると、テスト環境モードになる。開発者のタブをクリックすると項目がプルダウンで表示されるので、APIキーをクリック。
公開可能キー(例: pk_test_XXXXXXXXXXXXXXXXXXXXXXXX
)と秘密キー(例: sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
)をコピーする。
フロントエンドでの環境変数設定
環境変数ファイルの設定
フロントエンドプロジェクトのルートディレクトリに .env.production
ファイルを作成(または既存のファイルを編集)し、以下のように環境変数を設定する
GitHub Actionsのシークレット設定
GitHubリポジトリに移動し、「Settings」 > 「Secrets and variables」 > 「Actions」 を選択。
「New repository secret」 をクリックし、以下のシークレットを追加する
- 名前:
REACT_APP_STRIPE_PUBLISHABLE_KEY
- 値: 先ほどコピーしたStripeの公開可能キー(例:
pk_test_XXXXXXXXXXXXXXXXXXXXXXXX
)
GitHub Actionsワークフローの修正
.github/workflows/firebase-hosting-merge.yml
, .github/workflows/firebase-hosting-pull-request.yml
でenvにREACT_APP_STRIPE_PUBLISHABLE_KEYを追加。
interview-ai/.github/workflows/firebase-hosting-merge.yml
- name: Build frontend
run: npm run build
working-directory: ./frontend
env:
REACT_APP_BACKEND_URL: ${{ secrets.REACT_APP_BACKEND_URL }}
REACT_APP_FIREBASE_API_KEY: ${{ secrets.REACT_APP_FIREBASE_API_KEY }}
REACT_APP_STRIPE_PUBLISHABLE_KEY: ${{ secrets.REACT_APP_STRIPE_PUBLISHABLE_KEY }}
# 他の環境変数もここに追加
ワークフローファイルの作成に関しては、こちらの記事で記載。
フロントエンドコードでのStripeの初期化確認
フロントエンドコードでStripeを初期化する際に、環境変数から公開可能キーを取得する。
// 環境変数からStripeの公開可能キーを取得
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
バックエンドでのStripeの設定確認
バックエンドでもStripeを使用する場合、秘密キーを環境変数として設定し、セキュアに管理する。
環境変数の設定
バックエンドプロジェクトの環境変数ファイル(例: .env
)に以下を追加する。
STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
バックエンドコードでのStripeの初期化
バックエンドコードでStripeを初期化する際に、環境変数から秘密キーを取得
// backend/controllers/subscriptionController.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
GCP Secrets ManagerにStripe秘密キーを登録
Google Cloud SDKを使って、Stripeの秘密キーをSecret Managerに追加する。下記コマンドを実行する。
echo -n "<your_stripe_secret_key>" | gcloud secrets create STRIPE_SECRET_KEY --data-file=-
ちなみに、既存のシークレットを更新するためには、gcloud secrets versions add
コマンドを使用する。
Secret Managerに登録した秘密キーの確認
秘密キーが正しく登録されたか確認するには、次のコマンドを実行する。
gcloud secrets versions list STRIPE_SECRET_KEY
このコマンドを実行すると、シークレットのバージョンが表示される。最新のバージョンが表示されていれば、正しく追加されている。
Cloud Runの環境変数にシークレットを設定してデプロイ
gcloud run deploy
コマンドを使用して、シークレットを環境変数として指定して、下記コマンドを実行してCloud Runにデプロイする
YOUR_SERVICE_NAME
: Cloud Runのサービス名YOUR_PROJECT_ID
: Google CloudプロジェクトIDYOUR_PROJECT_NUMBER
: Google Cloudプロジェクトの番号YOUR_IMAGE_NAME
: デプロイするDockerイメージの名前YOUR_REGION
: Cloud Runが稼働するリージョン
-update-secrets
:既存のシークレットはそのままで、新しいシークレットを追加することができます。このコマンドでは、指定したシークレット(この場合はSTRIPE_SECRET_KEY
)がCloud Runの環境変数として追加され、他の既存のシークレットや環境変数には影響を与えない。もし他のシークレットも含めてすべての環境変数を更新したい場合は、--set-secrets
オプションを使う必要がある。
gcloud run deploy YOUR_SERVICE_NAME
--image gcr.io/YOUR_PROJECT_NUMBER/YOUR_IMAGE_NAME
--update-secrets STRIPE_SECRET_KEY=projects/YOUR_PROJECT_NUMBER/secrets/STRIPE_SECRET_KEY:latest
--platform managed
--region YOUR_REGION
--allow-unauthenticated
バックエンドのコード変更がある場合には、Cloud Runにデプロイする前に、まずコンテナイメージをビルドしてGoogle Container Registry(GCR)にアップロードする。
Secret Managerの設定や環境変数の変更適用だけの場合は、Cloud Rubへのデプロイだけで良い。
gcloud builds submit --tag gcr.io/interview-ai-20241006/backend .
無事デプロイが完了すると、ログイン後には下記の画面が表示される。
右上に現在のプランと、プラン変更のリンクを記載。プラン変更をクリックすると、プラン変更の選択とクレジットカード入力画面が表示される。
Stripeでのテスト決済を行うための設定手順
Stripe管理画面で商品作成、エンドポイント、イベントの許可の設定を進める。
商品の作成
- 左側のメニューから「商品カタログ」を選択し、「商品を追加」をクリックして、新しい商品を作成する。
- 商品名(例: “Standard Plan”)や価格(今回はrecurringなので、継続を選択)を設定する。
下記のようにStandardプランと、Premiumプランの2つの商品を作成した。
商品を作成すると、各商品ごとに価格IDという固有のID(通常「price」から始まる)が生成される。識別子としてコードで後ほど使用する。
Webhookエンドポイントとイベントタイプの設定
開発者→Webhooks→「Webhookエンドポイントを作成する」をクリックして、受け取るイベントタイプを選択して、追加する。
invoice.payment_succeeded
:支払いが成功したときにトリガーされるinvoice.payment_failed
:支払いが失敗したときにトリガーされるcustomer.subscription.deleted
:サブスクリプションがキャンセルされたときにトリガーされるcustomer.subscription.updated
:サブスクリプションが更新されたときにトリガーされる。このイベントは、無料プランから有料プランに切り替える際も、サブスクリプションの更新として処理される。
Webhookエンドポイントを追加する。ここでは、サーバーサイドのエンドポイントを指定。例: https://your-backend-url/api/webhook
フロントエンドの SubscriptionForm.tsx作成
環境変数からプランごとの価格ID(price_から始まる)を取得して、サブスクリプションのプラン選択時に商品の価格を表示する。
商品IDの環境変数はGithub Secretsに作成して保存する。
// src/components/SubscriptionForm.tsx
import {
CardElement,
Elements,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import axios from "axios";
import React, { useState } from "react";
import { Alert, Button, Col, Container, Form, Row } from "react-bootstrap";
// 環境変数からStripeの公開可能キーを取得
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
// 商品IDを環境変数から取得
const STANDARD_PLAN_PRICE_ID =
process.env.REACT_APP_STANDARD_PLAN_PRICE_ID || "";
const PREMIUM_PLAN_PRICE_ID = process.env.REACT_APP_PREMIUM_PLAN_PRICE_ID || "";
interface SubscriptionFormProps {
userId: string;
}
const SubscriptionForm: React.FC<SubscriptionFormProps> = ({ userId }) => {
const [selectedPlan, setSelectedPlan] = useState<string>("free");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
return (
<Elements stripe={stripePromise}>
<Container className="mt-4">
<Row className="justify-content-center">
<Col md={6}>
<CheckoutForm
userId={userId}
selectedPlan={selectedPlan}
setSelectedPlan={setSelectedPlan}
setError={setError}
setSuccess={setSuccess}
loading={loading}
setLoading={setLoading}
/>
{error && (
<Alert variant="danger" className="mt-3">
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="mt-3">
{success}
</Alert>
)}
</Col>
</Row>
</Container>
</Elements>
);
};
interface CheckoutFormProps {
userId: string;
selectedPlan: string;
setSelectedPlan: React.Dispatch<React.SetStateAction<string>>;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setSuccess: React.Dispatch<React.SetStateAction<string | null>>;
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}
const CheckoutForm: React.FC<CheckoutFormProps> = ({
userId,
selectedPlan,
setSelectedPlan,
setError,
setSuccess,
loading,
setLoading,
}) => {
const stripe = useStripe();
const elements = useElements();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSuccess(null);
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
setError("カード情報が取得できませんでした。");
return;
}
setLoading(true);
try {
// 支払い方法の作成
const { error: paymentMethodError, paymentMethod } =
await stripe.createPaymentMethod({
type: "card",
card: cardElement,
});
if (paymentMethodError) {
setError(
paymentMethodError.message || "支払い方法の作成に失敗しました。"
);
setLoading(false);
return;
}
// バックエンドにサブスクリプション作成リクエストを送信
const response = await axios.post(
`${process.env.REACT_APP_BACKEND_URL}/api/create-subscription`,
{
userId,
paymentMethodId: paymentMethod.id,
priceId:
selectedPlan === "standard"
? STANDARD_PLAN_PRICE_ID // 環境変数からスタンダードプランの価格IDを取得
: selectedPlan === "premium"
? PREMIUM_PLAN_PRICE_ID // 環境変数からプレミアムプランの価格IDを取得
: null,
}
);
if (!response.data.clientSecret) {
throw new Error("クライアントシークレットが取得できませんでした。");
}
const { clientSecret } = response.data;
// Stripeの支払い確認
const result = await stripe.confirmCardPayment(clientSecret);
if (result.error) {
setError(result.error.message || "支払いに失敗しました。");
} else {
if (result.paymentIntent?.status === "succeeded") {
setSuccess("サブスクリプションが正常に作成されました。");
}
}
} catch (err: any) {
setError(err.response?.data?.error || "予期せぬエラーが発生しました。");
} finally {
setLoading(false);
}
};
return (
<Form onSubmit={handleSubmit} className="p-4 border rounded bg-light">
<Form.Group controlId="planSelect" className="mb-3">
<Form.Label>プランを選択</Form.Label>
<Form.Control
as="select"
value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)}
disabled={loading}
>
<option value="free">無料プラン</option>
<option value="standard">スタンダードプラン (1,980円/月)</option>
<option value="premium">プレミアムプラン (3,980円/月)</option>
</Form.Control>
</Form.Group>
<Form.Group controlId="cardElement" className="mb-3">
<Form.Label>カード情報</Form.Label>
<div className="border p-2 rounded">
<CardElement
options={{
style: {
base: {
color: "#000",
fontSize: "16px",
"::placeholder": {
color: "#aab7c4",
},
backgroundColor: "#fff",
padding: "10px",
},
invalid: {
color: "#fa755a",
},
},
}}
/>
</div>
</Form.Group>
<Button
variant="primary"
type="submit"
disabled={!stripe || loading}
className="w-100"
>
{loading ? "処理中..." : "プランを変更"}
</Button>
</Form>
);
};
export default SubscriptionForm;
バックエンドの subscriptionController.js 作成
routes/subscriptionRoutes.js
このファイルは、サブスクリプション関連のルート(エンドポイント)を定義
// backend/routes/subscriptionRoutes.js
const express = require("express");
const router = express.Router();
const subscriptionController = require("../controllers/subscriptionController");
// サブスクリプション作成エンドポイント
router.post("/create-subscription", subscriptionController.createSubscription);
// サブスクリプション更新エンドポイント
router.post("/update-subscription", subscriptionController.updateSubscription);
module.exports = router;
controllers/subscriptionController.js
このファイルは、サブスクリプションの作成や更新、Webhook処理のロジックを実装。
環境変数に価格IDを設定:
.env
ファイルに価格ID(pirice_から始まる)を追加し、コントローラー内で環境変数から価格IDを取得- 価格IDの環境変数はGoogle Cloud Secret Manager に格納してから、Cloud Run にデプロイする
Stripeの初期化
Stripe
クラスをインスタンス化し、環境変数から秘密鍵を取得してStripe APIを使用できるようにする。
サブスクリプション作成処理 (createSubscription
メソッド)
- ユーザーIDと支払い方法、プランの価格IDをリクエストから取得・
- Firebase UIDを使用してユーザー情報をデータベースから取得し、Stripeの顧客を作成または取得
- 支払い方法をStripeの顧客にアタッチし、デフォルトの支払い方法を設定
- サブスクリプションを作成し、その結果をユーザー情報に保存
- 最後に、クライアントにサブスクリプションのIDとクライアントシークレットを返す
Webhook処理 (handleWebhook
メソッド)
- StripeからのWebhookリクエストを処理。
- リクエストからイベントの種類を確認し、それに応じた処理(支払い成功、失敗、サブスクリプションの削除や更新)を行う
- 各イベントに対する処理は、それぞれ専用のハンドラー関数で行う。
// backend/controllers/subscriptionController.js
const Stripe = require("stripe");
const dotenv = require("dotenv");
const User = require("../models/userModel");
dotenv.config();
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
/**
* サブスクリプション作成エンドポイント
*/
exports.createSubscription = async (req, res) => {
const { userId, paymentMethodId, priceId } = req.body;
try {
// Firebase UIDを使用してユーザーをデータベースから取得
const user = await User.findOne({ uid: userId });
if (!user) {
return res.status(404).json({ error: "ユーザーが見つかりません" });
}
// Stripeの顧客を作成または取得
if (!user.stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
payment_method: paymentMethodId,
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
user.stripeCustomerId = customer.id;
await user.save();
}
// 支払い方法を顧客にアタッチ
await stripe.paymentMethods.attach(paymentMethodId, {
customer: user.stripeCustomerId,
});
// 顧客のデフォルトの支払い方法を設定
await stripe.customers.update(user.stripeCustomerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
// サブスクリプションを作成
const subscription = await stripe.subscriptions.create({
customer: user.stripeCustomerId,
items: [{ price: priceId }],
default_payment_method: paymentMethodId,
expand: ["latest_invoice.payment_intent"],
payment_behavior: "default_incomplete",
});
// 最新の請求書と支払い意図を確認
if (
!subscription.latest_invoice ||
!subscription.latest_invoice.payment_intent ||
!subscription.latest_invoice.payment_intent.client_secret
) {
console.error(
"支払い意図が取得できませんでした。サブスクリプション情報:",
subscription
);
return res
.status(500)
.json({ error: "サブスクリプションの作成に失敗しました。" });
}
console.log("ユーザー情報:", {
uid: user.uid,
email: user.email,
plan: user.plan,
subscriptionStatus: user.subscriptionStatus,
});
console.log("送信された価格ID:", priceId);
// ユーザーのサブスクリプション情報を更新
user.subscriptionId = subscription.id;
user.subscriptionStatus = "incomplete";
user.plan =
priceId === process.env.STRIPE_STANDARD_PRICE_ID
? "standard"
: "premium";
user.subscriptionStart = new Date();
await user.save();
res.status(200).json({
subscriptionId: subscription.id,
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
});
} catch (error) {
console.error("サブスクリプション作成エラー:", error);
res.status(500).json({ error: error.message });
}
};
/**
* サブスクリプション更新エンドポイント(プランの切り替え)
*/
exports.updateSubscription = async (req, res) => {
const { userId, newPriceId } = req.body;
try {
// ユーザーを取得
const user = await User.findOne({ uid: userId });
if (!user || !user.subscriptionId) {
return res
.status(404)
.json({ error: "サブスクリプションが見つかりません" });
}
// サブスクリプションを取得
const subscription = await stripe.subscriptions.retrieve(
user.subscriptionId
);
// サブスクリプションのアイテムを更新
const updatedSubscription = await stripe.subscriptions.update(
user.subscriptionId,
{
cancel_at_period_end: false, // 現在の期間終了時にキャンセルしない
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: "create_prorations", // プロレーションを作成
}
);
// 最新の請求書と支払い意図を確認
if (
!updatedSubscription.latest_invoice ||
!updatedSubscription.latest_invoice.payment_intent ||
!updatedSubscription.latest_invoice.payment_intent.client_secret
) {
console.error(
"支払い意図が取得できませんでした。サブスクリプション情報:",
updatedSubscription
);
return res
.status(500)
.json({ error: "サブスクリプションの更新に失敗しました。" });
}
// ユーザーのプランを更新
user.plan =
newPriceId === process.env.STRIPE_STANDARD_PRICE_ID
? "standard"
: "premium";
user.subscriptionStatus = "active"; // 更新後のステータスに応じて変更
await user.save();
res.status(200).json({
subscriptionId: updatedSubscription.id,
clientSecret:
updatedSubscription.latest_invoice.payment_intent.client_secret,
});
} catch (error) {
console.error("サブスクリプション更新エラー:", error);
res.status(500).json({ error: error.message });
}
};
/**
* Webhookエンドポイント
*/
exports.handleWebhook = async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // 生のボディ
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Webhookエラー:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// イベントタイプに基づいて処理を分岐
switch (event.type) {
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
default:
console.warn(`未処理のイベントタイプ: ${event.type}`);
}
res.status(200).json({ received: true });
};
// Helper関数: サブスクリプションのプランを判別
function getPlanFromPriceId(priceId) {
// Stripeの価格IDとプランをマッピング
const priceToPlan = {
[process.env.STRIPE_STANDARD_PRICE_ID]: "standard",
[process.env.STRIPE_PREMIUM_PRICE_ID]: "premium",
};
return priceToPlan[priceId] || "free";
}
// Webhookハンドラー関数
async function handlePaymentSucceeded(invoice) {
const customerId = invoice.customer;
const subscriptionId = invoice.subscription;
// Stripeからサブスクリプションを取得
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const plan = getPlanFromPriceId(priceId);
// データベースでユーザーを更新
const user = await User.findOne({ stripeCustomerId: customerId });
if (user) {
user.subscriptionStatus = "active";
user.plan = plan;
user.subscriptionStart = new Date(subscription.start_date * 1000); // UNIXタイムスタンプをDateに変換
await user.save();
}
}
async function handlePaymentFailed(invoice) {
const customerId = invoice.customer;
// データベースでユーザーを更新
const user = await User.findOne({ stripeCustomerId: customerId });
if (user) {
user.subscriptionStatus = "past_due";
await user.save();
}
}
async function handleSubscriptionDeleted(subscription) {
const customerId = subscription.customer;
// データベースでユーザーを更新
const user = await User.findOne({ stripeCustomerId: customerId });
if (user) {
user.subscriptionStatus = "canceled";
user.plan = "free";
user.subscriptionId = null;
user.subscriptionStart = null;
await user.save();
}
}
// サブスクリプションが更新された際に呼び出されるハンドラー
async function handleSubscriptionUpdated(subscription) {
const customerId = subscription.customer;
const priceId = subscription.items.data[0].price.id;
// Stripeの価格IDとプランをマッピングする関数
const plan = getPlanFromPriceId(priceId);
// データベースでユーザーを更新
const user = await User.findOne({ stripeCustomerId: customerId });
if (user && plan) {
user.plan = plan;
user.subscriptionStatus = subscription.status; // 例えばactive, past_due, canceledなど
user.subscriptionStart = new Date(subscription.start_date * 1000); // UNIXタイムスタンプをDateに変換
await user.save();
}
}
Stripe決済の動作確認
テスト用クレジットカードの使用
テストモードでは、Stripeが提供するテスト用のクレジットカード番号を使用できる。例えば、以下のようなカード番号を使用する。
- カード番号:
4242 4242 4242 4242
- 有効期限: 任意の未来の日付
- CVC: 任意の3桁の数字
Stripe決済処理とDBでのプラン変更を確認
表示されたプラン変更画面で、プランを選択したのちに、クレジット入力画面でテスト用クレジットカード番号を入力して、プランを変更ボタンをクリック。
今回はプレミアムプランに変更して、プランを変更ボタンを押したところ、無事サブスクリプションが正常に作成されたとのcalloutが表示された。
Stripeダッシュボード→左サイドバーの「顧客」→顧客一覧から該当の顧客を選択 すると、サブスクリプションに成功したことが表示されていた。
MongoDBのユーザー情報でも、subscriptionId
とplan
フィールドが正しい値に更新されていて、プランもfreeからpremiumに更新されたことを確認できた。
ユーザーの画面でも、プランがプレミアムに変わり、文字起こしの時間と小見出し生成回数もプレミアムの内容に切り替わったことを確認できた。
まとめ
本記事では、「インタビューAI」におけるStripe決済の導入手順を詳しく解説した。GCPとMongoDBを活用して、テスト環境の構築から月額課金システムの実装までのステップを記載。
まず、Stripeのテスト用APIキーを取得し、月額プランを設定。次に、GCPとMongoDBでバックエンドを整え、最終的にテスト環境で決済機能を確認する。これにより、安全かつ効率的に決済機能を実装できる。