👛

みんサポのポイント機能を作る上で考えたこと

2024/08/29に公開

はじめに

「みんなでお買い物サポートクラブ」(以下、みんサポ)は、クチコミ投稿やアンケート回答などのユーザーアクションに応じてポイントがもらえるプログラムです。
また貯めたポイントは現金に交換できるため、どんな次回以降のどんなお買い物にも使いやすくとても便利な機能となっています!

私はみんサポの開発において主にポイント機能の設計・実装を担当しました。
本記事ではポイント機能の開発において、どのようなことを考えて開発を行ったのかその概要を紹介します。

みんサポの概要

※詳しくはこちらをご覧ください。

前提

弊社で採用しているRuby on Railsを前提とした説明やサンプルコードを掲載しています。

技術スタックについてはこちらに詳しく掲載していますのでぜひご覧ください。
https://zenn.dev/mybest_dev/articles/1fda6f67c82724

ポイント機能の概要

みんサポのポイント機能の開発にあたって、以下のような要件がありました。

  1. ポイント獲得

    • クチコミ投稿、アンケート回答など特定のユーザーアクションによってポイントが付与されること
    • 管理者によるポイント手動付与が可能なこと
  2. ポイントの有効期限

    • 付与されたポイントには有効期限があり、期限を過ぎると使用できなくなること
  3. ポイントの消費

    • 貯まったポイントは現金に交換できること
    • 有効期限が近いポイントから優先的に消費されること
  4. ポイント履歴

    • いつ、どのようなアクションでポイントを獲得したか、またどのような理由でポイントを消費したかが記録されること
    • ポイント獲得・消費には保留状態があり、特定のフローを経て確定されること
    • ポイントの失効履歴が記録されること
  5. 有効期限切れ予定の通知

    • 有効期限切れが近づいたポイントがある場合、ユーザーに通知されること

テーブル設計

これらの要件を満たすために以下のようなテーブル設計を行いました。

主なテーブルは以下の3つです。(※説明用に簡略化しています)

point_earnings

point_spendings

  • ポイントの消費データを記録するテーブル
  • またポイント消費のトリガーとなったアクションはポリモーフィック関連を用いて紐づけられる

point_transactions

  • ポイントの獲得と消費を紐づけるテーブル
  • 有効期限が近いポイントから順に消費していくため、どの獲得ポイントから何pt消費したかを記録する必要がある
例: 5,000pt消費する場合
- 獲得ポイント①: 3,000pt(2024/9/30まで有効)
- 獲得ポイント②: 3,000pt(2024/10/31まで有効)

=> ①から3,000pt、②から2,000pt消費
=> ②には1,000pt残る

設計上の考慮事項

上記のテーブル設計に加え、ポイント機能の設計にあたり考慮したポイントをいくつか紹介します。

1. ポイント残高の取得方法

ポイント残高の取得方法として2つのアプローチを検討し、最終的には2を選択しました。

  1. 計算済みの残高をデータベースに保存する
  2. ポイントの加算・減算記録をもとに都度残高を計算する

実装イメージ↓

class Customer < ApplicationRecord
  has_many :point_earnings

  def point_balance
    valid_point_earnings = point_earnings.preload(:point_transactions).valid
    valid_point_earnings.sum(&:remaining_amount)
  end
end

class Point::Earning < ApplicationRecord
  has_many :not_cancelled_point_transactions, -> { joins(:point_spending).merge(Point::Spending.not_cancelled) },
    class_name: 'Point::Transaction', foreign_key: :point_earning_id, inverse_of: :point_earning

  def remaining_amount
    return 0 unless confirmed?
    return 0 if expired?

    amount - not_cancelled_point_transactions.sum(&:amount)
  end
end

選択の理由

  • 実装のシンプルさ

    • ポイントの加算や減算が発生するたびに残高を更新するような仕組みが不要
    • 残高取得時の日時をもとに計算するのでポイントの確定ステータスや有効期限切れのポイントを自動的に考慮でき、定期的なクリーンアップ処理が不要
  • データの一貫性

    • 常に最新のポイント獲得・消費履歴に基づいて残高が計算されるため、データの不整合が発生するリスクが低くなる
  • 柔軟性

    • 将来的にポイント計算のロジックを変更する必要が生じた場合でも、過去の獲得・消費データさえあれば新しいロジックで正確に残高を再計算可能
    • また特定日時時点の残高の取得も容易

デメリットとその対策

  • パフォーマンスの懸念

    • データ量が増加すると残高の計算に時間がかかる可能性あり
    • 対策: 現時点では問題になっていませんが、将来的には計算済み残高を保存する方法への移行も視野に入れています。
  • 複雑なクエリ

    • ポイント獲得・消費が確定しているかどうかや有効期限を考慮しつつ残高を計算するには、比較的複雑なSQLクエリが必要
    • 対策: 全てをSQLで完結させるのではなく、必要に応じてアプリケーションレベルでのデータ加工も組み合わせることで再利用性や可読性を高める工夫をしています

2. アクションとの紐づけ

ポイント付与の対象となるアクション(例: クチコミ投稿、アンケート回答)をどのように紐づけるか検討しましたが、最終的には2を選択しました。

  1. ポイント付与アクションごとに中間テーブルを設ける
  2. ポリモーフィック関連を用いてポイント付与元のアクションを紐づける

実装例↓

class Customer < ApplicationRecord
  has_many :point_earnings

  def increase_point!(amount:, reason:, point_earnable: nil, confirmed: true)
    confirmed_at = confirmed ? Time.zone.now : nil
    point_earnings.create!(amount:, reason:, point_earnable:, confirmed_at:)
  end
end

選択の理由

  • 柔軟性
    • さまざまな種類のアクション(クチコミ投稿、アンケート回答、管理者による手動付与)ごとに独立したテーブルが不要
    • 将来的に新しいポイント付与アクションを追加する際も、データベーススキーマの変更が不要
  • フレームワークのサポート
    • Active Recordではポリモーフィック関連をサポートしているため、簡潔なコードで実装可能
    • アプリケーションレイヤーでのバリデーションやロジックを共通化できる

デメリットとその対策

  • 外部キー制約が効かない
    • ポリモーフィック関連を使用すると外部キー制約を設定できない
    • アプリケーションレイヤーのバリデーションを通らないルートでの書き込みによって不整合が発生するリスクがある
    • 対策: DML実行によるデータ更新は行わず、必ずアプリケーションレイヤーのバリデーションを通るようにする。また後述するデータの整合性チェックを定期的に実施し、不整合が発生したことを即時に検知できるようにする。

3. セキュリティとデータ整合性

ポイントは金銭的価値を持つため、セキュリティとデータ整合性の確保は非常に重要です。

主な対策

  • トランザクションの適切な利用

    • ポイントの付与や使用に関わる操作は、トランザクションを用いてアトミックに処理
    • 途中でエラーが発生した場合でも不整合データが発生しないようにする
  • ポイント獲得、消費データのモニタリング

    • Redashでユーザーごとのポイント獲得・消費履歴を可視化し、異常なポイント付与や消費がないか監視
  • 定期的な整合性チェック

    • システムが正しく動作していることを確認するため、定期的にポイント残高の整合性チェックのバッチジョブを実行
    • 想定される不整合のパターンごとに事前に対応策を定義しておき即時対処できるようにする

今後の展望や改善点

みんサポは現在も進化を続けています。以下は、今後取り組む予定の主な改善点と拡張計画です。

ポイント付与ルールの柔軟化

現在はクチコミ投稿とアンケート回答が主なポイント獲得方法ですが、今後はそれ以外にも様々な手段でポイントを獲得できるように拡充していく予定です。
1アクションで完結するのではなく複数の条件を達成した場合にポイントを付与するような機能や、単発のキャンペーン施策として「◯◯をしたらポイント付与」のようなアイデアも考えられます。

現在、ポイント付与のルールはコードに直接埋め込まれているため、新しいキャンペーンやルールを追加する度にに開発者の介入が必要です。
もしそういった単発の施策が頻繁に実施される場合は管理画面からポイント付与ルールを柔軟に設定できるようにできればと思っています。

まとめ

ポイント機能の設計においては、ポイント残高の取得方法やアクションとの紐づけ方法など、様々な観点から検討を重ねました。
もしポイント機能を導入する際には、上記のような設計上の考慮事項を参考にしていただければ幸いです。

参考にさせていただいた記事

https://zenn.dev/matsubokkuri/articles/point-system-with-expiration
https://engineering.reiwatravel.co.jp/blog/newt-point-immutable-data-model

Discussion