Firebaseでサイト内ポイントシステム構築してみた。

こんにちは、みやジックです。

今回はFirebaseのFirestoreでサイト・アプリ内のポイントシステムを構築してみたので、DB設計や運用方針等について記事にしようと思います。

また、こちらの記事は[Flutter大学](https://flutteruniv.com/)の共同勉強会にて2022年11月30日に発表した内容となっていますので、Flutter大学の方はそちらのアーカイブ動画もご覧いただければと思います！


# 目次
[1.はじめに](#1-はじめに)
[2.ポイントシステム](#2-ポイントシステム)
[3.有効期限について軽く考える](#3-有効期限について軽く考える)
[4.PointEvent](#4-pointevent)
[5.有効期限についてちゃんと考える](#5-有効期限についてちゃんと考える)
[6.現時点での総Pointについて](#6-現時点での総pointについて)
[7.おわりに](#7-おわりに)
[8.ちなみに](#8-ちなみに)

# 1. はじめに
さて、今回は「スキ街」というサービス内のポイントシステムを構築する際にFirestoreを使って設計を行ったのでそちらについて書いていこうと思うのですが、まずそもそもスキ街自体がどんなサービスなのかわからないとイメージも付きにくいと思うので簡単にスキ街について説明します。

### スキ街とは
一言で言うとサイト内のポイントを介したクラウドソーシングのマッチングサービスです。

下記の図をご覧ください。
![スクリーンショット 2022-11-27 15.55.39.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935513/cf6d92a4-2d46-416b-1ddc-0ef25f8bda62.jpeg)

図にあるようにスキ街では発注者と受注者をマッチングし、発注者は受注者に依頼をお願いします。
受注が決まると発注者は依頼料をスキ街に支払い、依頼が完了するとスキ街はスキ街ポイントを受注者に付与します。
受注者はスキ街ポイントをスキ街内でギフト等と交換できます。

上記がスキ街についてのざっくりとした説明になります。


# 2. ポイントシステム

と言うことでポイントシステムはスキ街の根幹的な部分なのですが、ポイントシステムをFirestoreで組んでみると意外と考えることが多かったです。
特に下記の2点が大きな悩みどころでした。

① 堅牢なシステムである必要がある。
② 有効期限を作りたい。

まず①についてです。
スキ街のポイントについては、「溜まったぜラッキー」と言った感覚のものではなく、依頼の受注報酬として受け取るものなのでポイント自体が付与されなかったり、何らかが原因で使ってないのに減ってしまうなどがあれば大問題です。また、ユーザが自由に自分のポイントを増やすことができたらそれはそれで大問題でスキ街運営側が大きな被害を受けてしまいます。そこでポイントシステムはユーザや悪意のある第3者に改ざんされないようにする必要があります。

次に②についてです。
詳しく説明すると長くなるのですが、スキ街のようにサイト内のポイントシステムを構築すると[前払い式支払い手段](https://www.s-kessai.jp/businesses/prepaid_means_overview.html)といって資金決済法という法律の対象になります。そして、いくつかの条件を満たすと財務局長等への届出と発行保証金の供託等が義務付けられます。
そして、この法の適用対象外となる一つの方法が発行するポイントに有効期限をつけることです。有効期限を6ヶ月以内に定めることで上記の法の適用外となり特に届出等を出す必要がなくなります。
そこでスキ街ではポイントの有効期限を6ヶ月に定めることとしました。

# 3. 有効期限について軽く考える

さて、有効期限について考えていきたいのですが、一番最初に思いついたのはポイントのドキュメントに有効期限フラグのようなものを持たせて有効期限が切れたらそれを立てたり、そもそも有効期限の日にちを持たせてその有効期限が過ぎているポイントは使えなくするみたいなふうにしたらいいかなと思いました。

ただ、少し考えるとわかるのですが、上記の方法だとうまくいきません。

例えばあるユーザのポイント履歴が下記の通りだとします。
2022年1月1日に1000ポイント付与されたとします。
2022年2月1日に500ポイント使用したとします。
2022年6月1日に有効期限が切れるのは500ポイントになります。

ここでフラグを使って管理すると1000ポイントのドキュメントのフラグでは500ポイント失効したという情報を持てないのです。
仮に1000ポイントのドキュメントに使用ポイントや残高ポイントと言ったフィールドを用意することでどれだけ使って、どれだけ失効するのかを管理することはできるかもしれませんが、保有ポイントの計算等複雑になっていく気がします。

そこで次の章で説明するのですが、PointEventドキュメントというものを作成し、ここでポイントの獲得(GET),使用(USE),失効(LOST)を発行することで有効期限つきのポイントシステムを実現しました。

# 4. PointEvent
3章で書いた通り、スキ街のポイントはPointEventsコレクションを用意して管理することにしました。
Pointはユーザに紐づくデータなのでUserドキュメントのサブコレクションとしてPointEventsコレクションを用意します。
またPointEventドキュメントのスキーマについて下記に示します。

```yaml:PointEvents
users:
    pointEvents:
      type: String //GET,USE,LOST
      point: int
      createdAt: Timestamp
      effectiveDate: Timestamp? //GETの場合のみ
```

実際のスキ街ではこの他にポイント獲得に紐づくドキュメントのリファレンスやポイント使用に紐づくドキュメントのリファレンスもフィールドとして持たせていますが、今回の記事ではあまり重要ではないのでそれらのフィールドについては省略しています。

さて、それぞれのフィールドについてですが、まずtypeについては3章でも少し触れた通りそのPointEventが獲得、使用、失効のどのイベントなのかを決めるフィールドになります。
pointはそのイベントで発生したpoint数、createdAtはそのイベントの発生時刻、effectiveDateについてはGETの場合のみの値なのですが、有効期限を示しています。上で決めた通り有効期限は6ヶ月としているので、createdAtの6ヶ月後の日を設定することになります。

# 5. 有効期限についてちゃんと考える
PointEventドキュメントも用意したので、有効期限についてちゃんと考える土台が用意できました。
まず、GETのPointEventには有効期限(effectiveDate)があります。
これを定期的にチェックして、有効期限切れのポイントがあればLOSTのPointEventを発行すればいいわけです。

「定期的にFirestoreのデータをチェックしてドキュメントを発行する」←こういうのが得意なのが[Cloud Functions for Firebase](https://firebase.google.com/docs/functions)(以下CloudFunctionsと呼びます。)です。

CloudFunctionsには [Pub/Sub triggers](https://firebase.google.com/docs/functions/pubsub-events) と言って定期的に関数を実行するトリガーが用意されています。

もちろんFirebase上で動く関数なのでFirestoreとの親和性はとても高くFirestoreの読み取りと書き込みも自由自在です。(JavaScriptかTypeScriptが書ければ、、、)

ということで、有効期限の処理についてはFunctionsで行いました。
前提としてFunctionsは毎日深夜の2時に実行します。

そしてFunctionsの処理を言葉にしてみると以下のようになります。

①その日までに有効期限が切れているポイント(その日切れるものも含む)より今まで消費・失効したポイントが少ない場合、その差分だけその日に失効する。

②逆に、その日までに有効期限が切れているポイント(その日切れるものも含む)以上に今まで消費・失効したポイントがある場合はその日にポイントは失効しない。

？？？

言葉だとちょっと難しいですね、、式にしてみます！！

①GetPoint(expired) ≧ UsePoint(All) + LostPoint(All)の場合
失効するポイントは
GetPoint(expired) - (UsePoint(All) + LostPoint(All)) = LostPoint(new)

②GetPoint(expired) < UsePoint(All) + LostPoint(All)の場合
ポイントは失効しない

|  変数  |  意味 |
| ---- | ---- |
|  GetPoint(expired)  |  有効期限切れのポイントの合計 |
|  UsePoint(All)  |  使用済みポイントの合計  |
|  LostPoint(All)  |  失効済みポイントの合計  |
|  LostPoint(new)  |  今回失効するポイント  |

言葉と式にすると定時処理で行うべき処理が明確になりました。
PointEventsの発行のために今回用意したFunctionsは以下の３つになります。

#### addPointGet():依頼ドキュメントのステータスが完了に変更時
依頼が完了した時にPointEvent(GET)を発行

#### addPointUse():ギフト申請ドキュメント作成時
ポイントを確認して、PointEvent(USE)を発行

#### checkLostPoint():毎日定時実行
失効するポイントを計算して、あればPointEvent(LOST)を発行

CloudFunctionsを使うメリットは他にもあってセキュリティ上のメリットもあります。
Firestoreのデータはセキュリティルールによって守られています。

PointEventsのセキュリティルールでは"誰に"、"何の操作を"許可するかを決めます。
PointEventsはユーザが自由に書き込めてはいけないので、セキュリティルールでユーザの書き込みを禁止することにしました。

CloudFunctionsはセキュリティルールを無視してアクセスできるAdmin権限を付与できるので、CloudFunctionsのみ書き込みができる状況になります。
そのため、上記に書いた通り全てのTypeのPointEventはCloudFunctionsから発行することにしました。



# 6. 現時点での総Pointについて
さて、ポイントの発行のイベントをPointCollectionに格納することができました。
これでPointCollectionを取得することでポイント履歴を表示できます。

また、実際にはこのPointCollectionから現時点での総ポイントを計算する必要があります。
現時点での総ポイントはPointEventについてGETは正、USEとLOSTは負でPoint数を全て足し合わせることで計算ができます。これは下記の式で表せます。

現時点での総ポイント = GetPoint(All) - (UsePoint(All) + LostPoint(All))

ということでアプリ側、今回でいえばFlutterで構築したスキ街アプリからPointEventコレクションを全て読むことで総ポイントが計算できます。しかし、総ポイントを表示するたびにPointEventコレクションを全て読むのはあまり得策ではなさそうです。総ポイントはマイページで表示できるようにしたいため、マイページを開くたびにPointEventコレクションを全て読む必要が出てきます。PointEventドキュメントが増えれば増えるほど、パフォーマンス的にもコスト的にもあまりよろしくないです。

そこで、今回はPointEventドキュメントとは別にUserのSecretProfileドキュメントを作成し、そこに現時点での総ポイントを保存することにしました。

```yaml:SecretProfile
users:
    pointEvents:
      type: String
      point: int
      createdAt: Timestamp
      effectiveDate: Timestamp?
    SecretProfile:
        point: // 現在の総ポイント
```

SecretProfileドキュメントはuserドキュメントのサブコレクションにしています。
userドキュメントに総ポイントを持たせない理由は、userドキュメントはユーザのプロフィールにあたるユーザ名やアイコン画像のURLなどを持っており、他のユーザからアクセスできるのに対し、現在の総ポイントについては他のユーザからアクセスできないようにしたいためです。

用意したSecretProfileのpointに対して、PointEventが発行されるたびに計算する必要があるので次の関数を作成しました。

#### onCreatepointEvent():PointEventドキュメント作成時
発行されたPointEventがGET の場合 ```point(SecretProfile) += point(pointEvent)```
発行されたPointEventがUSE or LOST の場合 ``` point(SecretProfile) -= point(pointEvent) ```

これはPointEventが発行されるたびに現時点のポイントにポイントを加算・減算する関数になっています。

※ Flutter大学内での共同勉強会でこの関数はTransaction処理にするべきじゃないかとの意見をいただいたのですが、記事執筆までに対応が間に合わなかったので、Transaction処理についても対応したら追記予定です。

これで、SecretProfileのpointは常に現時点での総ポイントになります。
よって、Flutterアプリで総ポイントを表示する際にはこちらを参照することでデータの読み取り件数が1件で済むようになりました。

Point使用時にはFlutterアプリ側で使用するポイント数とSecretProfileのpointを比較することでポイントが不足していないかをチェックしていますが、ポイント使用のロジックが走った場合にはCloudFunctions側で再度PointEventコレクションから現時点での総ポイントを再計算して不足がないかをチェックしています。


# 7. おわりに

今回は有効期限付きのポイントシステムをFirebaseのFirestoreとFunctionsで構築してみました。
スキ街のGitHubリポジトリはFlutter大学のorganizationにあるので、Flutter大学の方はリポジトリも見てみてください！

あと、[スキ街](https://sukimachi.app/)もちらっと覗いてみてくれると喜びます :eyes:


# 8. ちなみに

本記事の内容とは関係ありませんが、この記事の執筆中に疲れてしまってAIが記事書いてくれたらいいのに〜と思ったので、今流行りの[ChatGPT](https://chat.openai.com/)にこの記事のタイトルを入れた結果を載せておきます。

![スクリーンショット 2022-12-02 22.51.23.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935513/1b66a548-6573-a9ac-66fa-0cde915d3ce7.jpeg)


んー、、、代わりに記事を書いてくれては無いですが、大きなゲームチェンジは近いのかも、、