- Introduction.
- Stripe Test Environment Settings
- Environment variable settings on the front end
- Confirm Stripe settings on the backend
- Setup procedure for making a test payment in Stripe
- Front-end SubscriptionForm.tsx creation
- Creation of backend subscriptionController.js
- Confirmation of Stripe payment operation
- summary
Introduction.
Currently developing a personal “Interview AI” that can transcribe an hour’s worth of audio in 15 seconds and automatically convert it into a natural conversational format.
Currently, we have almost finished development in the development environment and are in the process of migrating to the production environment.
The front end is developed with React, the back end with Node.js, and the database with MongoDB.
In this project, we will use the Stripe test environment to implement and verify the operation of a payment for a change in a monthly billing plan.
Stripe Test Environment Settings
Obtaining API Keys for Testing
Use **Publishable Key and Secret Key** when using the Stripe test environment
After creating a Stripe account and navigating to the dashboard, turn on the “Test Environment” switch in the navigation to check the operation in the test environment.
You will then be in the test environment mode. Click on the Developer tab and the items will appear in the pull-down menu, click on API Key.
Copy the publicly available key (e.g., pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXX
) and the secret key (e.g., sk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX)
.
Environment variable settings on the front end
Environment Variable File Settings
Create a .env.production
file (or edit an existing file) in the front-end project root directory and set environment variables as follows
Secret settings for GitHub Actions
Go to the GitHub repository and select Settings > Secrets and variables > Actions.
Click “New repository secret” and add the following secret
- Name:
REACT_APP_STRIPE_PUBLISHABLE_KEY
- value: the Stripe public key you just copied (e.g.,
pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXX)
Fix GitHub Actions workflow
In .
github/workflows/firebase-hosting-merge
.yml
, .github/workflows/firebase-hosting-pull-request.yml
add REACT_APP_STRIPE_PUBLISHABLE_ to env. KEY to env in .githbase-hosting-pull-request.yml.
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 }}
# 他の環境変数もここに追加
The creation of workflow files is described in this article.
Confirmation of Stripe initialization in front-end code
When initializing Stripe in the front-end code, obtain the publicly available key from an environment variable.
// 環境変数からStripeの公開可能キーを取得
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
Confirm Stripe settings on the backend
If Stripe is also used on the back end, set the secret key as an environment variable and manage it securely.
Setting Environment Variables
Add the following to the environment variable file (e.g. .env)
of the backend project
STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
Stripe initialization in backend code
Get secret key from environment variable when initializing Stripe in backend code
// backend/controllers/subscriptionController.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
Register Stripe secret key in GCP Secrets Manager
Add Stripe secret keys to Secret Manager using Google Cloud SDK. Execute the following command
echo -n "" | gcloud secrets create STRIPE_SECRET_KEY --data-file=-
Incidentally, to update an existing secret, use the gcloud secrets versions add
command.
Confirmation of secret key registered in Secret Manager
To verify that the secret key has been registered correctly, execute the following command
gcloud secrets versions list STRIPE_SECRET_KEY
When this command is executed, the version of the secret is displayed. If the latest version is displayed, it has been added correctly.
Deploy with secret set in Cloud Run environment variable
Deploy to Cloud Run using the gcloud run deploy
command, specifying the secret as an environment variable and executing the following command
YOUR_SERVICE_NAME
: Cloud Run service nameYOUR_PROJECT_ID
: Google Cloud project IDYOUR_PROJECT_NUMBER
: Google Cloud project numberYOUR_IMAGE_NAME
: Name of the Docker image to deployYOUR_REGION
: Region where Cloud Run is running
-update-secrets
: Allows you to add a new secret while leaving the existing one in place. This command adds the specified secret (in this caseSTRIPE_SECRET_KEY
) as an environment variable for Cloud Run and does not affect other existing secrets or environment variables. If you want to update all environment variables, including other secrets, you must use the--set-secrets
option.
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
If there are back-end code changes, first build and upload a container image to the Google Container Registry (GCR) before deploying to Cloud Run.
If you only want to apply Secret Manager settings and environment variable changes, you only need to deploy to Cloud Rub.
gcloud builds submit --tag gcr.io/interview-ai-20241006/backend .
After successful deployment, the following screen is displayed after login.
The current plan and a link to change plans are listed in the upper right corner. Clicking on Change Plan will bring up the plan change selection and credit card entry screen.
Setup procedure for making a test payment in Stripe
Proceed with product creation, endpoints, and event permissions on the Stripe admin page.
Merchandise Creation
- Select “Product Catalog” from the menu on the left and click “Add Product” to create a new product.
- Set the product name (e.g., “Standard Plan”) and price (in this case, “recurring,” so choose “recurring”).
Two products were created, the Standard plan and the Premium plan, as shown below.
When an item is created, a unique ID (usually starting with “price”) called a price ID is generated for each item. It is used later in the code as an identifier.
Configure Webhook endpoints and event types
Click on Developer -> Webhooks -> “Create Webhook Endpoint”, select the event type you want to receive and add it.
invoice.payment_succeeded
: Triggered when paymentis
successfulinvoice.payment_failed
: Triggered when payment failscustomer.subscription.deleted
: Triggered when a subscription is canceledcustomer.subscription.updated
: Triggered when a subscription is updated. This event is also processed as a subscription update when switching from a free plan to a paid plan.
Add a Webhook endpoint. Here, specify the server-side endpoint. Example: https://your-backend-url/api/webhook
Front-end SubscriptionForm.tsx creation
Get the price ID (starting with price_) for each plan from the environment variable and display the price of the product when selecting a subscription plan.
The environment variable for the product ID should be created and stored in 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 = ({ userId }) => {
const [selectedPlan, setSelectedPlan] = useState("free");
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [loading, setLoading] = useState(false);
return (
<Col>
{error && (
{error}
)}
{success && (
{success}
)}
</Col>
);
};
interface CheckoutFormProps {
userId: string;
selectedPlan: string;
setSelectedPlan: React.Dispatch<React.SetStateAction>;
setError: React.Dispatch<React.SetStateAction>;
setSuccess: React.Dispatch<React.SetStateAction>;
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction>;
}
const CheckoutForm: React.FC = ({
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 (
プランを選択
setSelectedPlan(e.target.value)}
disabled={loading}
>
無料プラン
スタンダードプラン (1,980円/月)
プレミアムプラン (3,980円/月)
カード情報
<div>
</div>
<Button type="submit" disabled="{!stripe">
{loading ? "処理中..." : "プランを変更"}
</Button>
);
};
export default SubscriptionForm;
Creation of backend subscriptionController.js
routes/subscriptionRoutes.js
This file defines subscription-related routes (endpoints)
// 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
This file implements the logic for creating and updating subscriptions and Webhook processing.
Set price ID in environment variable:.
- Add price IDs (starting with pirice_) to the
.env
file and get the price ID from the environment variable in the controller - Price ID environment variables are stored in Google Cloud Secret Manager and then deployed to Cloud Run.
Stripe initialization
- Instantiate the
Stripe
class, get the private key from the environment variable, and make the Stripe API available.
Subscription creation process (createSubscription
method)
- Retrieve user ID, payment method, and plan price ID from request
- Create or retrieve Stripe customers by retrieving user information from the database using Firebase UIDs
- Attach payment method to Stripe customer and set default payment method
- Create subscriptions and store the results in user information
- Finally, return the subscription ID and client secret to the client
Webhook processing (handleWebhook
method)
- Handles webhook requests from Stripe.
- Identify the type of event from the request and process it accordingly (successful payment, failure, subscription deletion or renewal)
- Each event is handled by its own dedicated handler function.
// 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();
}
}
Confirmation of Stripe payment operation
Use of credit card for testing
In test mode, you can use a test credit card number provided by Stripe. For example, use the following card numbers
- Card number:
4242 4242 4242 4242
- Expiration date: Any future date
- CVC: Any 3-digit number
Confirm plan change in Stripe payment processing and DB
On the Change Plan screen that appears, select a plan, then enter a test credit card number on the credit input screen and click the Change Plan button.
This time, I changed to the Premium plan and pressed the Change Plan button, and a callout was displayed indicating that the subscription had been successfully created.
When selecting the appropriate customer from the Stripe dashboard -> “Customers” in the left sidebar -> Customer List, it was shown that the subscription was successful.
I was able to confirm that the subscriptionId and
plan
fields were updated to the correct values in the MongoDB user information, and the plan was also updated from free to premium.
The user’s screen also confirmed that the plan had changed to Premium and that the transcription time and number of subheadings generated had also switched to Premium content.
summary
This article details the steps for implementing Stripe payments in “Interview AI”, from building a test environment to implementing a monthly billing system, utilizing GCP and MongoDB.
First, obtain a Stripe test API key and set up a monthly plan. Next, prepare the backend with GCP and MongoDB, and finally check the payment functionality in the test environment. This allows us to implement payment functions securely and efficiently.