【Next.js】任意のクレジットカードでStripe決済

f:id:nekorokkekun:20191018181525p:plain:w1000
以前まとめたこちらの記事の続きとなります。

nekorokkekun.hatenablog.com
nekorokkekun.hatenablog.com
nekorokkekun.hatenablog.com

以前の段階で、StripeとFirestoreとFirebase Authenticationのユーザーを同時に作成し、登録したクレジットカードで単発決済を行うことに成功しました。

こちらの記事では、複数のクレカをFirestoreとStripeへ登録した上で、任意のクレジットカードでStripe決済ができるようにしていきます。

全体像の把握

f:id:nekorokkekun:20191018152325p:plain

以前の実装も含んでいるため複雑に見えますが、今回やるべきことは以下の通りです。

リポジトリのクローン

まずは今回使用するリポジトリをクローンしていきましょう。

$ git clone git@github.com:mesh1nek0x0/stripe-nextjs-on-firebase.git

ブランチの移動

現在、リポジトリをクローンした時点でmasterブランチにいます。
以下のgitコマンドを入力し、register-cardというタグのブランチを取得してください。

$ git checkout -b hogehoge register-card

hogehogeは、register-cardというタグの付くブランチをコピーする新たなブランチ名です。このブランチの命名はなんでも構いません。

そして次のコマンドを入力します。

$ yarn install
$ ✨  Done in 20.32s.

と出れば成功です。

Firebaseとの接続

次にクローンしたリポジトリを、事前に作成しておいたFirebaseのプロジェクトと接続させましょう。

Firebaseプロジェクトの作成については、以下をご覧の上、参考にしてください。
nekorokkekun.hatenablog.com

.env(新規作成)

apiKey=xxxxxxxxxxxxxxxxxxxxxxxx
authDomain=xxxxxxxxxxxxxxxxxxxxxxxx
databaseURL=xxxxxxxxxxxxxxxxxxxxxxxx
projectId=xxxxxxxxxxxxxxxxxxxxxxxx
storageBucket=
messagingSenderId=xxxxxxxxxxxxxxxxxxxxxxxx
appId=xxxxxxxxxxxxxxxxxxxxxxxx
stripeKey=xxxxxxxxxxxxxxxxxxxxxxxx

.envのstripeKeyに関しては以下が参考になります。
nekorokkekun.hatenablog.com


.firebaseerc

{
  "projects": {
    "default": "xxxxxxxxxxxxxxx"
  }
}

商品一覧・詳細画面の作成

商品一覧表示のページと、商品1つ1つの詳細画面表示ページを作成します。
pagesディレクトリ以下に、下記のディレクトリとファイルを作成しましょう。

products
 ┣ [product].js
 ┗ index.js

index.jsが商品一覧ページ、[product].jsが商品詳細ページとなります。
Next.jsでは、ファイル名を[ ]で囲うことでGETパラメータによる動的なルーティングが可能となります。

商品一覧画面の作成

まずは商品一覧画面を作成します。

pages/products/index.js

import React from "react";
import Link from "next/link";
import { firestore } from "../../lib/firebase";

function Products(props) {
  return (
    <>
      <h1>pages/products</h1>
      <Link href="/">
        <a>Go back to TOP</a>
      </Link>
      <ul>
        {props.products.map(product => {
          return (
            <li key={product.id}>
              <Link href="/products/[product]" as={`/products/${product.id}`}>
                <a>{product.pageName}</a>
              </Link>
              <ul>
                <li>{product.artistName}</li>
                <li>{product.monthlyFee}</li>
              </ul>
            </li>
          );
        })}
      </ul>
    </>
  );
}

Products.getInitialProps = async () => {
  const result = await firestore
    .collection("fanPages")
    .get()
    .then(snapshot => {
      let data = [];
      snapshot.forEach(doc => {
        data.push(
          Object.assign(
            {
              id: doc.id
            },
            doc.data()
          )
        );
      });

      return data;
    });
  return { products: result };
};

export default Products;

商品詳細画面の作成

次に商品詳細画面を作成します。

import React from "react";
import { auth, firestore } from "../../lib/firebase";
import Link from "next/link";

class Product extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      isLogin: false,
      user: {}
    };
  }

  componentDidMount() {
    auth.onAuthStateChanged(authUser => {
      if (authUser) {
        const state = Object.assign(this.state, {
          isLogin: true,
          user: authUser
        });
        this.setState(state);
      } else {
        this.setState({
          isLogin: false,
          user: {}
        });
      }
    });
  }

  render() {
    return (
      <>
        <h1>pages/products/[product]</h1>
        <Link href="/products">
          <a>Go Back to Products List</a>
        </Link>
        <h2>PRODUCT DETAIL</h2>
        <ul>
          <li>PRODUCT NAME: {this.props.product.pageName}</li>
          <li>MONTHLY FEE: {this.props.product.monthlyFee}</li>
        </ul>
        <button>
          {this.state.isLogin
            ? `BUY as ${this.state.user.displayName}`
            : "PLEASE LOGIN"}
        </button>
      </>
    );
  }

  static async getInitialProps({ query }) {
    const result = await firestore
      .collection("fanPages")
      .doc(query.product)
      .get()
      .then(snapshot => {
        return snapshot.data();
      });
    return { product: result };
  }
}

export default Product;

特筆すべきポイントのみ解説をします。

ログイン状態か確認
  componentDidMount() {
    auth.onAuthStateChanged(authUser => {
      if (authUser) {
        const state = Object.assign(this.state, {
          isLogin: true,
          user: authUser
        });
        this.setState(state);
      } else {
        this.setState({
          isLogin: false,
          user: {}
        });
      }
    });
  }

コンポーネントがマウントされた段階で、アクセスユーザーがログインしているかどうかを確認しています。

ログインにはFirebase Authenticationを使用しています。
これは以前実装した通りなので、以下の記事をご覧ください。
nekorokkekun.hatenablog.com

ログインユーザーと未ログインユーザーで表示の出し分け
        <button>
          {this.state.isLogin
            ? `BUY as ${this.state.user.displayName}`
            : "PLEASE LOGIN"}
        </button>

こちらでは三項演算子を用いて、

  • ログインユーザーには、値段の表示されたボタンの表示
  • 未ログインユーザーには、「PLEASE LOGIN」というボタンの表示

を行っています。

商品情報の取得
    const result = await firestore
      .collection("fanPages")
      .doc(query.product)
      .get()
      .then(snapshot => {
        return snapshot.data();
      });
    return { product: result };

こちらは一般的なFirestoreのデータ取得のためのコードですが、注目すべきは

.doc(query.product)

です。

query.productには何が入っているかというと、GETパラメータのproducts/以降の乱数文字です。
そして、この乱数文字が何かというと、FirestoreのfanPagesの次層のドキュメント内に自動生成されたIDです。

GETのクエリパラメータに渡ってきた乱数文字に対応するIDのデータを取得し、pages/products/[product].jsへ引き渡しているというわけです。

Firestoreにデータの登録

これで商品一覧・詳細画面がそれぞれ作成されました。
しかし、まだ表示すべきデータが入っていないはずです。

そこでFirestoreのコンソールにアクセスし、データを直接登録していきましょう。
f:id:nekorokkekun:20191018165946p:plain
値は何でも構いませんが、データ取得後にリストレンダリングをするため、

  • fanPages(コレクション)
  • pageName
  • monthlyFee
  • artistName

はそのままで作成してください。

この状態で、
http://localhost:3000/products
にアクセスして見ましょう。

そうすると以下のような画面が表示されるはずです。
f:id:nekorokkekun:20191018165958p:plain

Firestoreに複数のドキュメントを登録すると、商品一覧数がそれに対応して増えていく仕組みです。

Payableコンポーネントの作成

次に登録したクレジットカードからどのカードで払うかを選択するためのPayableコンポーネントを作成していきましょう。

図で表すと、このような流れになります。
f:id:nekorokkekun:20191018171405p:plain

上記の図を以下のような流れで実現します。

  • クレジットカードをブラウザ上で選択
  • 選択されたクレジットカードのsourceを取得
  • 支払い確認 > 支払い確定の流れをhenadleChangeメソッドで作成

Payable.jsの作成

まずはPayable.jsコンポーネントを作成しましょう。

import { useState, useEffect } from "react";

function Payable(props) {
  // use hooks
  const [source, setSource] = useState({ key: "card", last4: "0000" });
  const [sources, setSources] = useState([
    { key: "dummy", last4: "choose payment card" }
  ]);

  // useEffect like componetDidMount
  useEffect(() => {
    const result = [
      { key: "card1", last4: "4242" },
      { key: "card2", last4: "5252" }
    ];
    // 読み込み終わったら、選択肢に反映
    setSources(result);
    // 取得できた最初の支払い方法をデフォルトに
    setSource(result[0]);
  }, []);

  const handleCharge = event => {
    event.preventDefault();
    alert(`${source.last4}(${source.key})で${props.amount}円はらいます`);
  };

  const handleChange = event => {
    const currentSource = sources.find(
      source => source.key === event.target.value
    );
    setSource(currentSource);
  };
  return (
    <>
      <form onSubmit={handleCharge}>
        <select value={source.key} onChange={handleChange}>
          {sources.map(value => {
            return (
              <option key={value.key} value={value.key}>
                {value.last4}
              </option>
            );
          })}
        </select>
        <button type="submit">Buy Now</button>
      </form>
    </>
  );
}

export default Payable;

こちらも特筆すべきポイントを解説していきます。

ReactHookの使用
import { useState, useEffect } from "react";

関数コンポーネントは従来、stateやライフサイクルフックを使用することができませんでした。
しかし、ReactHookを使用することでstateやライフサイクルフックと同様の機能を関数コンポーネントに実装できるようになりました。

上記のimportは、ReactHookのuseState、useEffectを使用するためのものです。

ちなみに、

  • useStateは、Stateと同様の機能の使用
  • useEffectは、componentDidMountと同様の機能の使用

を可能にするためのReactHookです。

stateを設定
  const [source, setSource] = useState({ key: "card", last4: "0000" });
  const [sources, setSources] = useState([
    { key: "dummy", last4: "choose payment card" }
  ]);

こちらでstateの実装をしています。

ダミーのクレカ情報を設定
  useEffect(() => {
    const result = [
      { key: "card1", last4: "4242" },
      { key: "card2", last4: "5252" }
    ];
    // 読み込み終わったら、選択肢に反映
    setSources(result);
    // 取得できた最初の支払い方法をデフォルトに
    setSource(result[0]);
  }, []);

現時点では、useEffectを使ってStateへダミーのクレジットカード情報を設定しています。

選択したカードをStateに設定
  const handleChange = event => {
    const currentSource = sources.find(
      source => source.key === event.target.value
    );
    setSource(currentSource);
  };

クレジットカードには1つ1つsourceが存在します。
このsourceには、Stripeがクレジットカードを特定するための情報が含まれていますが、セレクトボックスで選択したクレジットカードが「支払いで使用するカード」として認識されるために、Stateへ設定される形となります。

Payable.jsの埋め込み

作成したPayable.jsを[product].jsへ埋め込みましょう。

import React from "react";
import Payable from "../../components/Payable";
import { auth, firestore } from "../../lib/firebase";
import Link from "next/link";

class Product extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      isLogin: false,
      user: {}
    };
  }

  componentDidMount() {
    auth.onAuthStateChanged(authUser => {
      if (authUser) {
        const state = Object.assign(this.state, {
          isLogin: true,
          user: authUser
        });
        this.setState(state);
      } else {
        this.setState({
          isLogin: false,
          user: {}
        });
      }
    });
  }

  render() {
    return (
      <>
        <h1>pages/products/[product]</h1>
        <Link href="/products">
          <a>Go Back to Products List</a>
        </Link>
        <h2>PRODUCT DETAIL</h2>
        <ul>
          <li>PRODUCT NAME: {this.props.product.pageName}</li>
          <li>MONTHLY FEE: {this.props.product.monthlyFee}</li>
        </ul>
        {this.state.isLogin ? (
          <Payable amount={this.props.product.monthlyFee} />
        ) : (
          "PLEASE LOGIN"
        )}
      </>
    );
  }

  static async getInitialProps({ query }) {
    const result = await firestore
      .collection("fanPages")
      .doc(query.product)
      .get()
      .then(snapshot => {
        return snapshot.data();
      });
    return { product: result };
  }
}

export default Product;

以下のような画面が表示されていれば成功です。
f:id:nekorokkekun:20191018173306p:plain

Pay nowボタンを押すとアラートウィンドウが表示され、OKを押すと支払う流れになります。

まだStripeと接続していないため、あくまでブラウザ上での動きとなります。

また、クレジットカード自体もダミーで表示させているもののため、sourceを保有しているものではありません。

Payable.jsで登録したクレジットカードを表示できるようにする

以下のようにPayable.jsを編集します。

import { useState, useEffect } from "react";
import { firestore } from "../lib/firebase";

function Payable(props) {
  // use hooks
  const [source, setSource] = useState({ key: "card", last4: "0000" });
  const [sources, setSources] = useState([
    { key: "dummy", last4: "choose payment card" }
  ]);

  // useEffect like componetDidMount
  useEffect(() => {
    firestore
      .collection("stripe_customers")
      .doc(props.currentUid)
      .collection("sources")
      .onSnapshot(
        snapshot => {
          let newSources = [];
          snapshot.forEach(doc => {
            const id = doc.id;
            newSources.push({ key: id, last4: doc.data().last4 });
          });
          setSources(newSources);
          setSource(newSources[0]);
        },
        () => {
          const state = Object.assign(sources, {
            sources: []
          });
          setSources(state);
        }
      );
  }, [props.currentUid]);

  const handleCharge = event => {
    event.preventDefault();
    alert(`${source.last4}(${source.key})で${props.amount}円はらいます`);
  };

  const handleChange = event => {
    const currentSource = sources.find(
      source => source.key === event.target.value
    );
    setSource(currentSource);
  };
  return (
    <>
      <form onSubmit={handleCharge}>
        <select value={source.key} onChange={handleChange}>
          {sources.map(value => {
            return (
              <option key={value.key} value={value.key}>
                {value.last4}
              </option>
            );
          })}
        </select>
        <button type="submit">Buy Now</button>
      </form>
    </>
  );
}

export default Payable;

この状態では画面上にエラーが表示されます。

FirebaseError: Function CollectionReference.doc() requires its first argument to be of type non-empty string, but it was: undefined

これは、Firestoreの stripe_customers の次層に当たるドキュメント「uid」に何も入っていない、もしくは適切な値が入っていないことで表示されているのです。

Payable.js 15行目

.doc(props.currentUid)

現状では、currentUidには何も入っていません。

そのため、[product].jsの45行目を以下の様に編集しましょう。

<Payable amount={this.props.product.monthlyFee} currentUid={this.state.user.uid} />

すでにログインした状態かつクレジットカードの登録が済んでいれば、問題なくクレジットカード情報が取得され、先ほどのダミー同様に表示されるはずです。

もしクレジットカード登録が済んでいない場合は以下を参考にしてください。
nekorokkekun.hatenablog.com

動作確認

Buy nowボタンを押すと以下の様なアラートウィンドウが表示されるはずです。
f:id:nekorokkekun:20191018174310p:plain

この(wJQ8UakCNS3py0qU)という乱数文字は、Firestoreの stripe_customers > uid > sources の層で登録されているクレジットカード固有のIDになります。

Firebaseへ決済情報の登録

次にFirebaseへ決済情報の登録を行います。
f:id:nekorokkekun:20191018174906p:plain

  • Buy nowボタンを押す
  • sourceのidが送信され、決済情報をFirebaseに送信する
  • chargeドキュメントに決済情報が登録される

という流れで実装します。

Payable.jsの編集

再びPayable.jsを編集します。

import { useState, useEffect } from "react";
import { firestore } from "../lib/firebase";

function Payable(props) {
  // use hooks
  const [source, setSource] = useState({ key: "card", last4: "0000" });
  const [sources, setSources] = useState([
    { key: "dummy", last4: "choose payment card" }
  ]);

  // useEffect like componetDidMount
  useEffect(() => {
    firestore
      .collection("stripe_customers")
      .doc(props.currentUid)
      .collection("sources")
      .onSnapshot(
        snapshot => {
          let newSources = [];
          snapshot.forEach(doc => {
            newSources.push({ key: doc.data().id, last4: doc.data().last4 });
          });
          setSources(newSources);
          setSource(newSources[0]);
        },
        () => {
          const state = Object.assign(sources, {
            sources: []
          });
          setSources(state);
        }
      );
  }, [props.currentUid]);

  const handleCharge = event => {
    event.preventDefault();
    alert(`${source.last4}(${source.key})で${props.amount}円はらいます`);
    firestore
      .collection("stripe_customers")
      .doc(props.currentUid)
      .collection("charges")
      .add({
        source: source.key,
        amount: parseInt(props.amount)
      });
  };

  const handleChange = event => {
    const currentSource = sources.find(
      source => source.key === event.target.value
    );
    setSource(currentSource);
  };
  return (
    <>
      <form onSubmit={handleCharge}>
        <select value={source.key} onChange={handleChange}>
          {sources.map(value => {
            return (
              <option key={value.key} value={value.key}>
                {value.last4}
              </option>
            );
          })}
        </select>
        <button type="submit">Buy Now</button>
      </form>
    </>
  );
}

export default Payable;

変更点のみを解説します。

sourceのidを取得
          snapshot.forEach(doc => {
            newSources.push({ key: doc.data().id, last4: doc.data().last4 });
          });

先ほど説明した様に、クレジットカード登録時にStripeにyとって、sourceというカード識別・支払い情報識別のためのデータが作成されます。

適切に支払いを行うには、使用するカードを指定した際に、Stateへ「このカードで、このように支払いをする」という情報を取得しなければなりません。

key: doc.data().id,

と記述することで、sourceのidを取得できるため、使用するカードのsourceも取得できるというわけです。

支払い情報をFirestoreへ登録

  const handleCharge = event => {
    event.preventDefault();
    alert(`${source.last4}(${source.key})で${props.amount}円はらいます`);
    firestore
      .collection("stripe_customers")
      .doc(props.currentUid)
      .collection("charges")
      .add({
        source: source.key,
        amount: parseInt(props.amount)
      });
  };

handleChargeのメソッドは先ほどもありましたが、こちらではFirestoreに単発決済の情報を登録するためのメソッドを追記しました。

これによって、stripe_customers > charges 以降の層に単発決済の情報が登録されることとなります。

動作確認

Buy nowボタンを押し、アラートボタンもOKを押したらFirestoreに決済情報が登録されているはずです。動作確認をしてみましょう。
f:id:nekorokkekun:20191018175945p:plain

ただ、この状態では

  • Stripeに決済情報が渡っていない
  • 決済情報としてsourceとamountしか持っていない

ため、まだ単発決済はできていません。

ちなみに「決済情報としてsourceとamountしか持っていない」という問題を解消すると、以下の様なデータが登録される様になります。
f:id:nekorokkekun:20191018180110p:plain

Firebase FunctionsでStripeに単発決済処理を送信

f:id:nekorokkekun:20191018180202p:plain

  • Firebase FUnctionsでStripeに決済情報を渡す
  • 完全な決済情報をFIrestoreに登録する

という2点を実装していきます。

Firebase Functionsの登録

functions/index.jsにcreateStripeChargeというFunctionを記述します。

// 上記省略
exports.createStripeCharge = functions.firestore
  .document("stripe_customers/{userId}/charges/{id}")
  .onCreate(async (snap, context) => {
    const val = snap.data();
    try {
      // Look up the Stripe customer id written in createStripeCustomer
      const snapshot = await admin
        .firestore()
        .collection(`stripe_customers`)
        .doc(context.params.userId)
        .get();
      const snapval = snapshot.data();
      const customer = snapval.customer_id;
      // Create a charge using the pushId as the idempotency key
      // protecting against double charges
      const amount = val.amount;
      const idempotencyKey = context.params.id;
      const charge = { amount, currency, customer };
      if (val.source !== null) {
        charge.source = val.source;
      }
      const response = await stripe.charges.create(charge, {
        idempotency_key: idempotencyKey
      });
      // If the result is successful, write it back to the database
      return snap.ref.set(response, { merge: true });
    } catch (error) {
      // We want to capture errors and render them in a user-friendly way, while
      // still logging an exception with StackDriver
      console.log(error);
      await snap.ref.set({ error: userFacingMessage(error) }, { merge: true });
      console.error(error);
      console.log(`user: ${context.params.userId}`);
    }
  });

function userFacingMessage(error) {
  return error.type
    ? error.message
    : "An error occurred, developers have been alerted";
}

書き込めたらFirebase Functionsにデプロイ していきます。

$yarn install
$ yarn deploy

もし以下の様なエラーが表示された場合は、使用しているnodeのバージョンと、functions/package.jsonのenginesに指定されているnodeのバージョンが異なるということです。

error functions@: The engine "node" is incompatible with this module. Expected version "8". Got "10.16.3"
error Commands cannot run with an incompatible environment.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

解決方法は以下を参照してください。
nekorokkekun.hatenablog.com

動作確認

先ほど同様、ブラウザからBuy nowボタンを押してみましょう。

その後、Stripeのコンソールにアクセスし、サイドメニューから「支払い」を押すと、単発決済が反映されているはずです。
またFirestoreのchargeドキュメント内にも、完璧な単発決済情報が登録されているはずです。

これで任意のクレジットカードでStripe決済が完了しました!お疲れ様でした!