方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

[Interview AI] Stripe Payment Implementation: From test environment to monthly billing implementation using GCP and MongoDB

stripe-mongodb-gcp
This article can be read in about 40 minutes.

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.

https://www.interview-ai.site

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

YAML
- 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.

JavaScript
// 環境変数から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

ShellScript
STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX

Stripe initialization in backend code

Get secret key from environment variable when initializing Stripe in backend code

JavaScript
// 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

ShellScript
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

ShellScript
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 name
  • YOUR_PROJECT_ID: Google Cloud project ID
  • YOUR_PROJECT_NUMBER: Google Cloud project number
  • YOUR_IMAGE_NAME: Name of the Docker image to deploy
  • YOUR_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 case STRIPE_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.
ShellScript
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.

ShellScript
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 payment is successful
  • invoice.payment_failed: Triggered when payment fails
  • customer.subscription.deleted: Triggered when a subscription is canceled
  • customer.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.

JavaScript
// 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)

JavaScript
// 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.
JavaScript
// 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 andplan 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.

Copied title and URL