Poolにおける残高管理の設計

こんにちは、エンジニアのpongzuです。 今日は、カンムが提供するプロダクト「Pool」の残高管理設計について書きます。

本記事では、まずPoolがどのような仕組みで動いているのか、投資やウォレットといったサービス仕様を簡単にご説明します。その後、それらをどのようにDB管理するかについて、設計の考え方を順を追って解説していきます。

仕様

Poolは簡単にいうと投資とVisaカードを組み合わせたプロダクトです。 アプリ上で口座開設を行い、ウォレットと呼ばれる機能に入金後、資産運用やカード決済に利用できます。 また、同時にウォレット残高と投資資産がカード利用可能額となり、Visaの加盟店でカード決済をすることができます。以下に仕組みを簡単に図解したものを貼ります。

※この仕様はシステム設計の説明のために簡易化したものです。正式な仕様はサービス紹介・利用規約をご参照ください。

ウォレット・投資・カードの機能について、もう少し詳しく説明します。

ウォレット

ウォレットは、入金の受け皿となる機能です。入金処理が行われると、その金額がウォレット残高として反映されます。また、入金以外にも、キャンペーン対象者へのポイント付与やカード支払いによるポイント還元などもウォレット残高に加算されます。

投資

ユーザーは、ウォレット残高から募集中のファンドへ投資申込を行うことができます。運用開始時にウォレット残高から投資資産へ資金が移行します。運用終了後には、資産(以下「償還額」と呼びます)を次の方法で利用できます。

  • ユーザーが指定する銀行口座への出金
  • 次の募集中ファンドへの再投資

カード

ウォレット残高と投資資産がカード利用可能額となり、Visa加盟店でカード決済を利用できます。 カード利用額は、利用月の月末に確定し翌月末にウォレット残高から支払われます。いわゆる「月末締め・翌月払い」のクレジットカード方式です。ただし、支払い額がウォレット残高を上回る場合に限り、カンムがファンドの持ち分を買い取る形で支払いに充てられます。これにより、ウォレット残高だけでなく、投資資産を活用したカード支払いが可能となっています。

以上のようにしてPoolは資産運用と預け入れた資金の流動性を両立させています。

DB設計

ここからは本題であるデータベース設計について説明します。 Poolの残高管理には、入金履歴・投資運用・カード支払いの3つが関係します。これらのテーブルがどのように関わり合い、残高管理を実現しているのかを順を追って解説します。 まず、主要なテーブルを抜き出してみると、deposit, investment_application, investment, card_payment, wallet_snapshotの5つが挙げられます。

※説明の便宜上すべてのテーブルにuser_idを持たせていますが、実際のリレーションとは異なります。

                                        Table "deposit"
     Column              |           Type           | Collation | Nullable |             Default
-------------------------+--------------------------+-----------+----------+----------------------------------
 id                      | bigint                   |           | not null | nextval('deposit_id_seq'::regclass)
 user_id                 | bigint                   |           | not null | 
 wallet_snapshot_id      | bigint                   |           | not null | 
 amount                  | numeric                  |           | not null | 

depositは入金履歴を管理するためのテーブルです。振込入金口座から入金通知を受けて入金額がウォレット残高に反映されます。 wallet_snapshot_idについては後述します。

                                        Table "investment_application"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 id             | bigint                   |           | not null | nextval('investment_application_id_seq'::regclass)
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null |

investment_application は投資申込を管理するためのテーブルです。募集中ファンドへの投資申込を行った際に作成され、運用開始日を迎えるまではウォレット残高に影響を与えません。また、その期間はキャンセルや金額の変更を行うことも可能です。

                                        Table "investment"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 application_id | bigint                   |           | not null |
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null |

investmentは運用開始後に作成される運用資産を表すテーブルです。investment_applicationのIDをPKとします。このレコードの作成時にウォレット残高から運用資産に残高が移行します。

                                        Table "card_payment"
     Column     |           Type           | Collation | Nullable |             Default
----------------+--------------------------+-----------+----------+----------------------------------
 id             | bigint                   |           | not null | nextval('card_payment_id_seq'::regclass)
 user_id        | bigint                   |           | not null |
 amount         | numeric                  |           | not null | 

card_paymentはカード支払いを管理するためのテーブルです。カード利用額の支払い処理で作成されます。詳しい内容は割愛しますが、実際にはcard_paymentが作成される前段にVisaから届いた決済確定電文を表すテーブルとそれを集計するテーブルが存在し、それを元にcard_paymentが作成されます。支払い額は、まずウォレット残高から差し引き、不足分は投資資産から相殺するという順番で充当されます。

                                         Table "wallet_snapshot"
     Column                 |           Type           | Collation | Nullable |             Default
---------------------------+--------------------------+-----------+----------+----------------------------------
 id                        | bigint                   |           | not null | nextval('card_payment_id_seq'::regclass)
 user_id                   | bigint                   |           | not null |
 previous_snapshot_id      | bigint                   |           |          | 
 amount                    | numeric                  |           | not null |

wallet_snapshot は、ウォレット残高を管理するためのテーブルです。ウォレット残高に変動があるイベントが発生すると、その時点の最新残高を反映した wallet_snapshot が作成されます。 previous_snapshot_idには前回のwallet_snapshotのIDを持たせて残高の推移を表現するようにしました。

以上が主要なテーブルの概要です。次に、ウォレット残高を投資やカードとどのように関連付けるかについて説明します。 ウォレット残高が必ず動くイベントが発生する際にはイベント管理用テーブルにwallet_snapshot_idをFKとして持たせて、そうではない場合は中間テーブルを介してウォレット残高とイベントを紐つけるようにしています。 例えば、depositは入金時に必ずウォレット残高に反映されるため、wallet_snapshot_idをFKとして持ちます。投資とカード支払いについてはウォレット残高から充当された場合のみ、中間テーブルを介してイベントとウォレット残高の変更が紐つけられます。

このようにすることで、資金の流れや原資の管理が容易となりました。 例えば、ウォレット残高や再投資から充当された運用資産の内訳は、investment テーブルとそれに紐づく中間テーブルを辿るクエリで算出可能です。

ここで簡単に実装例も書いておきます。 ウォレット残高から投資を開始するコードはこんな感じです。

※カンムではGoを使っています。

// トランザクションを開始
tx, err := db.Begin()
// 必ずロールバックするように設定
defer tx.Rollback()

// ユーザーIDでロックを取得
if err := LockUser(tx, userID); err != nil {return}

// 申込を取得してInvestmentテーブルに登録
application, err := GetInvestmentApplication(tx, userID)
if err != nil {return}

investment := Investment{
    ApplicationID: application.ID,
    Amount:        application.Amount,
}
if err := investment.Create(tx); err != nil {return}

// 最新のウォレットスナップショットを取得
previousSnapshot, err := GetLatestWalletSnapshotByUserID(tx, userID)
if err != nil {return}

// 新しいウォレット残高を計算し、ウォレットスナップショットを登録
newWalletAmount := previousSnapshot.Sub(investment.Amount)
walletSnapshot := WalletSnapshot{
    UserID:             userID,
    WalletAmount:       newWalletAmount,
    PreviousSnapshotID: previousSnapshot.ID,
}
if err := walletSnapshot.Create(tx); err != nil {return}

// 中間テーブルの作成
r := WalletSnapshotInvestment{
   WalletSnapshotID: walletSnapshot.ID,
   InvestmentID: investment.ID,
}
if err := r.Create(tx); err != nil {return}

// コミット
if err := tx.Commit(); err != nil {return}

スナップショットを用いた他機能への接続は、ロックのとり忘れやスナップショットとイベント管理用テーブルの作成忘れなどに注意する必要がありますが、以下のメリットもあるように思います。

  • ウォレット残高の参照を最新のwallet_snapshotを取得すれば良いので計算量が抑えられる
  • 過去のある時点のウォレット残高の算出が容易となった
  • 残高を都度更新する必要がない
    • たとえば、「ウォレット」というテーブルに残高(balance)を保持し、入出金や支払いのたびにアップデートをかけるといった実装が不要

設計思想

最後に、ウォレット残高をこのような設計で管理することに至った背景についてお話しします。 ウォレットは、ユーザーに必ず1つ割り当てられる、いわばリソース型のエンティティです。 しかし、ウォレット残高自体は入金、投資運用、カード支払いといったイベントの履歴から計算可能であり、以下のようなクエリで表現できます。

SELECT COALESCE(SUM(amount), 0)
    FROM (
      SELECT amount
      FROM deposit
      WHERE user_id = $1
    UNION ALL
      SELECT -amount
      FROM investment
      WHERE user_id = $1
    UNION ALL
      SELECT -amount
      FROM card_payment
      WHERE user_id = $1
  ) AS _

このようにイベントの履歴のみを記録しておく設計では、専用の残高管理用テーブルを用意する必要はありません。このアプローチは実際に弊社の別プロダクト「バンドルカード」でも採用しており、詳細はこちらでご紹介しています。

一方で、 Pool はスナップショット形式ではあるものの残高を実体として管理する設計を採用しています。背景としてはウォレット残高がPoolというサービスを支える基盤的な機能であり、他の機能と疎結合な状態である必要があると考えたからです。 投資やカードといった機能は、法律上ウォレットを介することで初めて成立します。この構造は、Poolというサービスが存在する限り変わることのない重要な要件です。現在は投資とカードが資金の移動先として存在していますが、将来的に「保険」など新しい機能が追加される可能性もあれば、「カード」機能を削除する必要が生じることも考えられます。こうした変化に柔軟に対応するためには、ウォレット残高を他の機能から独立させた疎結合な設計が求められました。

さらに、ウォレット残高はサービスの中心として、単に機能を支えるだけでなく、多様な資金の流れを生み出す役割も果たします。この資金の流れを適切に管理することは、会計上の重要な課題でもあります。そのため、ウォレット残高を独立したエンティティとして定義し、各イベントをそこに紐づけることで、資金の流れを管理できる設計を目指しました。

要するに、ウォレットを他の機能から切り離すことで、法的・会計的な要件に柔軟に対応しつつ、拡張性の高い設計を実現することが今回のポイントでした。 こうした考えを踏まえ、スナップショットを活用して他の機能と接続する設計を選択するという結論に至りました。

まとめ

以上、この記事では、Poolが提供する「ウォレット」「投資」「カード」という機能の概要と、それらをDBでどのように管理しているかについて説明しました。 特に、スナップショットを用いた管理は、残高の推移や他機能への資産の流れを把握しやすくする上で大きな利点がありました。もちろん、残高の設計は一筋縄ではいかず、メリットとデメリットの両面がありますが、現時点ではこの設計を採用して正解だったと感じています。

最後に

カンムではソフトウェアエンジニアを募集しています。ご興味ある方はぜひご連絡ください!

herp.careers