Open9

【個人開発】ダイエットアプリができるまで【Webアプリ】

smak.workssmak.works

firebaseとStripeの連携に苦戦した話

まずは拡張機能を入れる

Stripeを入れたいが全部自前で構築は厳しいと思うので拡張機能にお世話になろう。
するとCloud Functionsが必要なのでフリープランでは使えないとのこと。
恐る恐るフリープランから従量制にアップグレード。

これでReactの無限ループはご法度になった・・緊張

拡張機能のインストールが終わり、手順に従い両者の連携を済ませて改めてWebアプリを操作してみる

  • 新ユーザでGoogleログイン → Stripeに顧客できてる!
  • Stripeで商品つくる → firestoreにproductsできてる!

これは良いものだ!

次は課金ページを作るぞ

価格表をサイトに乗せて、課金できるようにしたい。
Stripeを色々みていたらノーコードで実装できそうだと判明。
https://stripe.com/docs/no-code/pricing-table

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
<stripe-pricing-table
    pricing-table-id="prctbl_*************"
    publishable-key="pk_live_***********"
>
</stripe-pricing-table>

早速このコードを入れてみると確かに商品が表示されて購入もできた!
めちゃ簡単やんけ

しかし問題に気がつく

  1. Webアプリにログインしてるんだからメールアドレスは入力させたくない
  2. 購入したら新しい顧客が毎回生まれてその新しい顧客がサブスク買ったことになる
  3. 買ったらサブスクページは購入済って出したい
  4. サブスク用の機能出し分けをしないといけないがどうやるんだ?

1はcustomer-emailこれで解決した

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
<stripe-pricing-table
    pricing-table-id="prctbl_*************"
    publishable-key="pk_live_***********"
+   customer-email="{{CUSTOMER_EMAIL}}"   
>
</stripe-pricing-table>

さて、2~4の問題は・・・
client-reference-idというものがあるらしいがよくわからなかった。

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>
<stripe-pricing-table
    pricing-table-id="prctbl_*************"
    publishable-key="pk_live_***********"
    client-reference-id="{{CLIENT_REFERENCE_ID}}"
>
</stripe-pricing-table>

色々調べたが、2~4の問題はノーコード機能では実現できないと判明した。

サーバーJSをしっかり書こう。続く。

smak.workssmak.works

続き

Stripeサブスク課金をJSでちゃんと書く

とりあえず公式やらQiitaやらZennやらに乗ってるコードをそのまま書く。
なるほどねpriceIdを渡してPOSTすればオッケーと。

const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

export default async function handler(req, res) {
  if (req.method === "POST") {
    const priceId = req.body.priceId;

    try {
      const session = await stripe.checkout.sessions.create({
        line_items: [
          {
            price: priceId,
            quantity: 1,
          },
        ],
        mode: "subscription",
        success_url: `${req.headers.origin}/?success=true`,
        cancel_url: `${req.headers.origin}/?canceled=true`,
      });
      res.redirect(303, session.url);
    } catch (err) {
      res.status(500).json(err.message);
    }
  } else {
    res.setHeader("Allow", "POST");
    res.status(405).end("Method Not Allowed");
  }
}

全然動かん。どうなってんねん
→ process.env使ってるのでローカルサーバー再起動であっさり解決。
(この間1時間弱)

というわけで動いた。
POSTするとStripeの該当商品ページに遷移して購入ができる。
しかし問題に気がつく。

  1. メールアドレス入らないじゃん
  2. てことは多分顧客も新規作成されるな。
  3. 状況がなにも変わってない。

1の問題はきっと前回みたいに解決策があるはずだ。
一旦ご飯でも食べて落ち着こう。


(5時間後)
https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elements
公式ドキュメントを見ていると・・・あった。
stripe.subscriptionsのcreateメソッドにcustomerIdを渡せばいいっぽい
絶対これだ。

const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

export default async function handler(req, res) {
  if (req.method === "POST") {
+   const customerId = req.body.customerId;
    const priceId = req.body.priceId;

    try {
      const session = await stripe.checkout.sessions.create({
+       customer: customerId,
        line_items: [
          {
            price: priceId,
            quantity: 1,
          },
        ],
        mode: "subscription",
        success_url: `${req.headers.origin}/?success=true`,
        cancel_url: `${req.headers.origin}/?canceled=true`,
      });
      res.redirect(303, session.url);
    } catch (err) {
      res.status(500).json(err.message);
    }
  } else {
    res.setHeader("Allow", "POST");
    res.status(405).end("Method Not Allowed");
  }
}

customerIdってのは多分顧客のページにあるIDってやつだな。
URLにもなってるしそうだろう。
→ あってた

とりあえずIDをハードコーディングしてPOSTしてみたら・・
グッド!メールアドレスが入った状態で買える!
買ったら既存の顧客にサブスク購入が付く!

完璧だ。続く。

smak.workssmak.works

サブスク購読中はサブスク購読中って出したい

今ある知識で考えるならば・・

  1. authをしてuidを取得する
  2. uidでusersコレクションから個人設定を取得する
  3. uidでcustomers/{uid}/subscriptionsからアクティブなものを取得する ←NEW
  4. uidでcustomersからcustomerIdを取得 ←NEW

冗長だが一応これで実現はできた。
こういうのサーバーサイドでやりたいけどよくわからず一旦このまま進む

次はサブスク中の機能出し分けをどうやって実現するか・・

サブスク状態をセキュリティルールに組み込む

まずは思考を整理。

  • フロント側でボタンの活性を制御する
  • サーバー側も無課金者から呼ばれないように制御する

最低限これは必要なはず。
ググりまくる。

プレミアムユーザーの課金の検証はサーバーサイドでやり、正しい購入であると検証できたらカスタムトークンを発行してカスタム認証を通しましょう。
そこでまずはカスタム認証に含めるトークンの設計から始めるのがよいと思います。
よくあるパターンとしては次のようなものがあります。

{
  "isPremium": true
}

これや!
https://zenn.dev/k2wanko/articles/3007acaafce03db58989#business-logic

続く

smak.workssmak.works

プレミアムユーザーの課金の検証はサーバーサイドでやり、正しい購入であると検証できたらカスタムトークンを発行してカスタム認証を通しましょう。

これを実現する方法を調べる

サブスク状態をセキュリティルールに組み込みたい

キーワード

  • カスタム認証
  • カスタムトークン
  • カスタムクレーム

このあたりを調べよう

キーワードの解釈

  • カスタム認証
    • 標準でできないようなユーザー認証を独自に構築すること?
  • カスタムトークン
    • カスタム認証に必要なトークン?1時間有効とか?
    • JSONでどうたら
  • カスタムクレーム
    • これがやりたいことに近そう
    • 任意の値をauthに追加できるらしい

https://qiita.com/zaburo/items/cd078d768bd9fd239bf4

やりたいことと実装イメージ

authに持たせたいのはこの2つ

  • stripeId: string
  • isPremium: boolean

これがあれば、authした時点でstripe用のcheckout.jsに渡すようの顧客IDも手に入って、サブスク購入画面の分岐、機能の出し分けが全部可能になる。
(現状はfirestoreに2回Readしてるのでよくない)

この値をいつ更新するのかがまだわからない
stripeIdは初authのときに生まれて変わらないし、isPremiumは「課金したとき」「日付変わったとき」とかでころころ変わるからauth時に毎回生成ってイメージになるのか・・?

とりあえずCloud Functionsは必要なので、まだ使ったことないけど環境構築をしていく。

Cloud Functionsを作る

いきなりエラー

Error: An unexpected error has occurred.

続く

smak.workssmak.works
firebase deploy --only functions

funcrionをデブロイするとエラー

Error: An unexpected error has occurred.

色々解決策があるけどどれもダメだった
結論、nodeのバージョンを18.0.0から16.0.0に変えたらすんなり解決

smak.workssmak.works

上のエラー、next本体の方にnpmエラーが出ちゃったので
nodist npm matchしたんだけどそうするとまたdeployのほうが同じエラーになってしまった。

このあたりの理解を深める必要がありそうだ・・

smak.workssmak.works

カスタムクレームを付与する

カスタムクレームについて

  • authUserに任意の値を付けられる機能
  • admin権限が要るためcloud functionsで付ける
  • カスタムクレームをrulesに使おう

https://firebase.google.com/docs/auth/admin/custom-claims
公式にもこう書いてある↓

有料/無料のサブスクライバーを区別する。

欲しい値

  • stripeId: string
  • isPremium: boolean

更新タイミング

  • stripeId
    これは初authでいいと思う。
    なのでStripe拡張機能入れてるから、customers/{uid}がcreateされたら発火して付与できそう。

  • isPremium
    これは課金したとき、日付が変わるとき。かな・・?よくわからない。
    課金したときは、customers/{uid}/subscriptions、customers/{uid}/paymentsのcreateどちらかで良さそう。どっちにするのかは次に調べる。
    日付が変わるときは、GCP内にスケジューラー的なのがあったと思うからそれで行けそう。
    問題はbool判定をどうやって行うか。
    フロントだとこういう判定でいいんだけどfunctionの書き方がわからないからここも調べる。

      const q = query(
        collection(db, "customers", authUser.uid, "subscriptions"),
        where("status", "==", "active"),
        where("current_period_end", ">", new Date())
      );

クライアントに渡したい

ほっといたらクライアントのデータが古いままになってそうだから調べる
https://firebase.google.com/docs/auth/admin/custom-claims?hl=ja#propagate_custom_claims_to_the_client

カスタムクレームをクライアントに伝達する
Admin SDKを介してユーザーで新しいクレームが変更された後、次の方法でIDトークンを介してクライアント側の認証済みユーザーに伝達されます。
カスタムクレームが変更された後、ユーザーはサインインまたは再認証します。結果として発行されるIDトークンには、最新のクレームが含まれます。
既存のユーザーセッションは、古いトークンの有効期限が切れた後にIDトークンを更新します。
IDトークンは、 currentUser.getIdToken(true)を呼び出すことによって強制的に更新されます。

有効期限を待つか、currentUser.getIdToken(true)するか。
後者をすべきタイミングってクライアントからわかるのか・・?
更新タイミングと一緒で課金した後と日付変わった後・・か

正解はわからないけど、できそうなイメージは徐々に湧いてきた気がする

続く

smak.workssmak.works

なんやかんやあって全部実装できた

結果的にcustom claimに持たせた値は3つ

  • stripeId: string
  • isPremium: boolean
  • priceId: string // 課金した商品のId

customers/{userId}のonCreate
→stripeIdをセット

customers/{userId}/subscriptions/{subscriptionId}のonWrite
→isPremiumとpriceIdをセット
subscription.statusが["active", "trialing", "past_due"]のいずれかでプレミアム状態となる

クライアントで最新のclaimを持てるように、currentUser.getIdToken(true)を行うことで対処。

カスタムクレームの付与はこれで終わり

smak.workssmak.works

カスタムクレームを使った課金かどうかのセキュリティルール

例えば、取得の場合はこうなった

        allow read: if isUserAuthenticated(userId)
          && (isPremium() || resource.data.timeAt >= (request.time - duration.value(40, 'd')));

途中で使ってるisPremium()はこれ↓とてもシンプル
== trueは要るのか微妙だけどあってもまあいいでしょう。

    function isPremium() {
      return request.auth.token.isPremium == true;
    }

これで、プレミアムなら全件とれて、それ以外は40日分だけとれる。
※フロントでは30日分だけまでしか操作できないけど週間で取得することもあってルールの方にはちょっとゆとりを設けてる。

ルールはフィルタではない

https://cloud.google.com/firestore/docs/security/rules-conditions?hl=ja#rules_are_not_filters

データを保護してクエリの記述を開始したら、セキュリティ ルールはフィルタではないことにご注意ください。コレクション内のすべてのドキュメントに対してクエリを記述できるわけではありません。Firestore によって返されるのは、現在のクライアントのアクセス権限が設定されているドキュメントだけです。

これには一度ひっかかってしまった。
サーバーにルールが書かれているから権限の無いデータは取得できないから勝手に絞られる、というのは間違い。
フロントでも権限の無いデータを取得しないようなクエリを書かないとエラーになる。

日付に関するルール

やりたかったことは直近30(40)日だけ取得できるルール
結論、データが足りなかったので日付型のデータを追加した。

もともとはyyyymmddの文字列だけだったので、ルールの中では計算ができなかった
durationというので計算可能な日付データを作って後は足し引きすればOK

resource.data.timeAt >= (request.time - duration.value(40, 'd'))

この記述で、データの日付がリクエスト時より40日前までが取得できる範囲となった