freeeの開発情報ポータルサイト

freeeカード ポイント機能のウラガワ

はじめに

この記事はfreeeアドベントカレンダー2023の9日目の記事です。

こんにちは!freeeカードチームのjunpayです。freeeカードを開発運用しています。 https://www.freee.co.jp/payment/card/

freeeカードLP

今年の6月に、お客様の利用額に応じてポイント還元を行うポイント機能をリリースしました。

このポイントはfreeeサービスの利用料金として充当することができ、freeeのサービスをよりお得に利用できる機能です。

https://corp.freee.co.jp/news/20230519unlimitedcard.html

本記事では、このポイント機能の設計についてお話をします。

freeeカードの全体アーキテクチャ

本題に移る前にfreeeカードのインフラアーキテクチャの概要について説明します。

freeeカードはフロントエンド(TS,React)、BFF(RoR)、Backend(Go)で構成されています。

そして、Backendでは各ドメインごとにマイクロサービスアーキテクチャを採用しています。

マイクロサービス概要図

参考 https://developers.freee.co.jp/entry/asynchronous-communication

ポイント機能は、お客様の利用料金の集計にフックするマイクロサービスの一部として実装されています。

ポイント機能について

ポイント機能と一口に言っても、複数の機能で構成されています。

  • ポイントの付与
    • 先月の利用料金から計算したポイントを付与する。
  • ポイントの利用
    • freeeサービスの利用料金の決済に対して所持ポイントを充当する。

本記事では、上記のうち「ポイントの付与」にフォーカスしてお話しします。

ポイント付与の仕組み

ポイントのトランザクション管理

ポイントのデータ構造を設計する際に、データ競合が発生しないように注意しました。

例えば、1つのテーブルで1事業所のポイント残高を管理すると、複数の更新が同時に行われた場合に競合が発生する可能性があります。

また、過去のポイント取引情報が残らなくなるといった問題も生じます。

1つのテーブルで1事業所のポイント残高を管理する場合のER図

そこで、ポイントの出納をトランザクション管理する仕組みを採用しました。

現在のポイント残高を取得するためのクエリも下記のように簡潔に済むようにしています。

ポイントの出納をトランザクション管理する。残高取得のSELECT文

「レコード数が増えた場合にクエリのレスポンス時間が大きくなるのでは?」と疑問に思う方もいらっしゃるかもしれません。

freeeカードのポイントは1事業所(=1company)ごとにポイント管理するという仕様です。また想定される運用ではポイントのトランザクションの発生が月に数件であるということが事前にわかっていました。

そのため、上記のようなクエリであってもパフォーマンスが問題になることは無いと判断し、シンプルさ優先することとしました。

ただし、実際に運用してみて想定よりもレコード数が増えた場合などは、令和トラベルさんで実装されてるようなイミュータブルデータモデルなど現在残高をキャッシュするような検討をしてもいいかなと思っています。

付与ポイント計算ルールについて

前述のように、お客様の先月の利用料金を元に計算されたポイントを付与します。

主に下記に注意して設計しました。

  • 付与ポイント計算ルールの構造
  • 既存コードと付与ポイント計算ルールの分離

付与ポイント計算ルールの構造

エンジニアがルールをまともに実装すると、複雑になりがちです。

還元率の変更やフィルタの方法も、今後の運用で変更される可能性がありますので、エンジニアとPdMは共通の認識を持つ必要があります。

そこで、付与ルールをエンジニアとPdMでも同じ認識を持てるように構造を下記に注意して設計しました。

  • シンプルであること
  • 条件、アウトカムを別々の事柄として定義し、かつ変更可能であること

freeeカードのポイント付与は基本的には先月の利用料金の総額に対し、あらかじめ設定された料率を掛けて算出します。

厳密には、「ポイント付与対象の決済(決済種別などでフィルタ)の総額」が対象になります。

さらに、総額に応じて還元率が変わる仕組みになっています。

利用総額/月 還元率
50万円以上/月 0.5%
50万円未満/月 0.2%

*2023年12月3日現在

そして上記のルールのモデルを簡易的に表現したものが下記になります。

ルールモデルのER図

point_rule_conditionsはポイント付与対象となる決済種別などの条件を管理するマスタです。point_rule_outcomesは還元率など最終的なアウトカムを管理します。point_rulesで条件とアウトカムをまとめて1ルールとして管理します。

例を交えて説明します。

ルールを各テーブル結合した1レコードとして表現するとこんな感じです。

id name merchant_types rate
1 freeeカード還元 freeeカード 0.3

ルールは重ねがけできるようにしています。

つまり、1つのお客様の利用料金の集計時に複数のルールにマッチした場合は両方のルールのポイントが付与されるようにしているのです。

例えば、こちらの還元率は1ルールのレコードで表現しているわけではなく。

利用総額/月 還元率
50万円以上/月 0.5%

下記のように2つのレコードで表現しています。

id name merchant_types rate
1 freeeカード還元 freeeカード 0.3
2 freeeカード還元 freeeカード 0.2

重ねがけを前提とすることで、1つのルールの中で「この場合は付与する」「この場合は付与しない」という複雑な設定を存在させないようにしています。

既存コードと付与ポイント計算ルールの実装上の分離

繰り返しになりますが、ポイントの計算と付与処理は、お客様の利用料金の集計にフックします。

もう少し細かく言うと、「利用料金の集計時に付与ポイントを算出し」「お客様の口座から引き落としを確認後に実際に付与」という流れです。

つまり、請求処理の各部とポイント処理はアトミック性が求められるため、既存の請求処理のコードベースの中にポイント処理を存在させています。

そのため、請求処理の既存のコードベースと如何に結合させないよう実装するかと、 計算ロジックを副作用無くテスタブルにすることが大切でした。

以下、詳細を説明します。

freeeカードのBackendはGo言語、Clean Architectureを採用しています。

https://developers.freee.co.jp/entry/clean-architecture

ポイント計算ロジックはビジネスルール(=domain)に属します。 domain層に属する計算ロジックを下記のようなシンプルなメソッドのみ開放します。

func (r *PointRule) Calc(clearings []*Clearing) ([]*Result, error)

計算に必要なデータを取得して計算ロジックに流し込むのはアプリのルール(=usecase)と、インタフェース(=repository)に属します。

ゆえに、domain層のCalcメソッドは「メソッドの内部でデータを取得する」などの副作用を持つことが無くなります。

今後、付与の計算ルールの改定が行われることは十分に考えられるため、関心部分でpackageを分けるのは重要なことだと考え、上記の設計にしました。

工夫した点/苦労した点

複雑にしすぎないようにする

実はfreeeカードのポイント機能は他社サービスに比較すると後発なのです。そのため未知数な部分が大きく、MVPをどうするかという議論が白熱しました。

設計実装からのアプローチとしては、関心ごとの分離など拡張性は担保しつつ、ルールやトランザクションはシンプルなものに落とし込みました。

最初は他社の設計事例などを参考に「なんでもできる」スキーマ構造で設計してたのですが、最終的にはテーブル数が1/5ほどになりました。

メンバーの「ポイント」というものに対する認識合わせ

これは設計とは直接関係ないのですが、 一般のポイントサービスとfreeeカードのポイントはちょっと違います。

これは「経理担当のペインを解消したい」という観点が施策内容に含まれているためです。

いわゆる一般のポイントサービスの固定観念を捨てた上で設計する必要があり、さらにそれをメンバーと認識合わせするのは時間がかかりました。

カードはミッションクリティカルな機能が多いのですが、その中でもポイントはよりシビアに扱わないといけない機能であるため、認識違いがあると事故に繋がりかねないため、この認識合わせを丁寧に何度も行いました。

さいごに

ここまで読んでくださりありがとうございます。

機能をリリースして半年、お客様がポイントをどれだけ貯めているのか、どれだけ利用されてるのかなどのデータが蓄積してきました。

freeeカードチームではそういった客観的なデータやお客様の声を元にして、サービス改善に取り組んでいきます。

それでは👋