1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

家計簿アプリに割り勘清算を実装する

Last updated at Posted at 2025-03-27

背景

彼女と同棲を始めてから、お互いに家計を共有できるように、我が家では自作の収支管理アプリ(React×TypeScript×Firebase)を使っている。

普段の買い物は、共通口座に紐づけたクレジットカードを使って、月末にクレジットカードの利用明細をアップロードすることで、支出をアプリに記録している。

しかし、キャッシュレスが普及した今でも、現金のみしか扱っていない店舗があり、どちらかが立て替えて支払うことがある。現金払いの場合、アプリでは手入力するが、ここで立て替えたことを同時に記録し、最終的にどっちがいくら多く払っているかを計算し、清算まで行いたいと考えた。

検討

ふたりで清算を行うのであれば、各々が立て替えた金額を合計して2で割り、お互いで相殺して不足分を片方に支払うことで清算は済むので、本来難しいロジックは不要である。

しかし、せっかく検討するのであれば、2人以上の立替え清算もできるようにしたい。
(我々の家計簿アプリでは2人以上は使用しないが。)
また、どちらかがどちらかの支払いを立て替えることもあるだろう。(私の個人的な買い物で、たまたま私が現金を持っておらず、代わりに彼女に払ってもらう・・・など)

イメージとしては、彼女や友人と旅行の際によく使っていた、割り勘計算アプリ「Walica(ワリカ)」のような機能を実装したいと考えた。(便利なのでおすすめ)

Walicaが便利なところは、複数人で複数回の割り勘を行ったとしても、お金のやり取り回数を少なくして清算ができるところである。今回はこの清算ロジックを実装してみる。

清算ロジック

割り勘計算のロジックをGoogleで調べてみると、いろいろな計算方法が出てくるが、
今回は以下の記事を参考にさせてもらう。

「最も支払わなかった人が最も支払った人に払えるだけ払う → 債権を再計算して繰り返す」

  1. 全員の出費を算出(払い過ぎは正、払わなさすぎは負の数)
  2. 降順でソート(出費過多が先頭)
  3. リストの最後(最大債務者, 出費=L)がリストの最初(最大債権者, F)に支払ってバランス(負債)を再計算
  4. 全員のバランスが 0 になるまで 2-3 を繰り返す

このロジックをTypeScriptで実装してみる。

自作アプリの仕様

清算ロジックに関わるDBと、画面に触れておく。(詳細は割愛する)

DB

DBにはFirebaseのFirestoreを使用しており、以下のフィールドのドキュメントを取引ごとに保存する。
支払った人をpayer、立て替えてもらった人を配列のpaidForに保持する。

{
  id: "transaction001",
  date: "2025-03-27",
  amount: 1200,
  content: "スーパー",
  type: "expense",
  category: "食費",
  userId: "userA", // 登録したユーザ
  payer: "userB", // 立て替えた人(支払い者)
  paidFor: ["userA", "userB", "userC"] // 立て替えてもらった人
}

画面

取引登録画面

取引を記録する画面に、立替えトグルボタンを追加した。
立替えをONにすると、ダイアログが表示され、支払った人と立て替えてもらった人を選択する。
立替えトグルがOFFの場合は、前述のpayerを空にすることで、立替払いか否かを判断する。

ui.png

清算画面

レポート機能の中に、清算画面を追加する。
誰が誰にいくら払えば良いかを表示し、ユーザごとに支払うべき金額、貰うべき金額を表示する。内訳を選択すると、そのユーザの立替えた(立て替えてもらった)金額と取引、支払った金額を表示する。

ui2.png

清算ロジックの実装

1. 全員の出費を算出(払い過ぎは正、払わなさすぎは負の数)

  • 全取引が格納されている配列(transactions)から、立替えが発生している取引のみを抽出し、フィルターした配列をreduceでユーザごとの残高、負債、支払い合計を計算する。

    // ユーザごとの残高、負債、支払い合計を算出
    function calculateSplitBalances(transactions: Transaction[]) {
      // 立替が発生している取引のみにfilter
      const splitBillTransactions = transactions.filter(
        (transaction) => transaction.payer);
      
      return splitBillTransactions.reduce<Record<string, {
        balance: number, 
        lending: number, 
        total: number}>>(
        (acc, transaction) => {
          if (!acc[transaction.payer]) {
            // キーが存在しなければ初期化
            acc[transaction.payer] = { balance: 0, lending: 0, total: 0 };
          }
          // 支払った金額としてbalanceに追加
          acc[transaction.payer].balance += transaction.amount;
          acc[transaction.payer].total += transaction.amount;
    
          // 立て替えられたユーザでループ
          transaction.paidFor.forEach((user) => {
            if (!acc[user]) {
              // キーが存在しなければ初期化
              acc[user] = { balance: 0, lending: 0, total: 0 };
            }
            // 1人当たりの金額
            const cost = Math.round(
              transaction.amount / transaction.paidFor.length
            );
    
            acc[user].balance -= cost;
            acc[user].lending += cost;
          });
          return acc;
        },
        {} as Record<string, {balance: number, lending: number, total: number}>
      );
    }
    

2. 降順でソート(出費過多が先頭)

  • 1.で取得したユーザごとの残高から、最大値と最小値を取得する
    // 各ユーザの貸借を計算する
    const splitBalances = calculateSplitBalances(transactions);
    
    // オブジェクトをオブジェクト配列に変換
    const balances = Object.entries(splitBalances).map(([key, value]) => ({
      id: key,
      balance: value.balance,
      lending: value.lending,
    }));
    
    // 一番貸し残高が多いユーザを取得
    const maxPaid = balances.reduce((prev, current) => {
      if (prev.balance >= current.balance) {
        return prev;
      } else {
        return current;
      }
    });
    
    // 一番貸し残高が少ないユーザを取得
    const minPaid = balances.reduce((prev, current) => {
      if (prev.balance <= current.balance) {
        return prev;
      } else {
        return current;
      }
    });
    

3. リストの最後(最大債務者, 出費=L)がリストの最初(最大債権者, F)に支払ってバランス(負債)を再計算

  • 最も残高が多いユーザの残高を、最も少ないユーザの残高に加算し、最も多いユーザの残高から減算する。
    const settleTransactions = [];
    const payAmount = Math.min(maxPaid.balance, Math.abs(minPaid.balance));
    
    settleTransactions.push({
      sender: minPaid.id,
      receiver: maxPaid.id,
      payAmount,
    });
    splitBalances[maxPaid.id].balance -= payAmount;
    splitBalances[minPaid.id].balance += payAmount;
    

4. 全員のバランスが 0 になるまで 2-3 を繰り返す

  • ループ処理にすると以下のようになる。(1.の処理は関数とした)
    // 各ユーザの貸借を計算する
    const splitBalances = calculateSplitBalances(transactions);
    
    const settleTransactions = [];
    while (true) {
      // オブジェクトをオブジェクト配列に変換
      const balances = Object.entries(splitBalances).map(([key, value]) => ({
        id: key,
        balance: value.balance,
        lending: value.lending,
      }));
    
      // 一番貸し残高が多いユーザを取得
      const maxPaid = balances.reduce((prev, current) => {
        if (prev.balance >= current.balance) {
          return prev;
        } else {
          return current;
        }
      });
    
      // 一番貸し残高が少ないユーザを取得
      const minPaid = balances.reduce((prev, current) => {
        if (prev.balance <= current.balance) {
          return prev;
        } else {
          return current;
        }
      });
    
      // 精算が完了したらループを抜ける
      if (minPaid.balance === 0 || maxPaid.balance === 0) break;
    
      const payAmount = Math.min(maxPaid.balance, Math.abs(minPaid.balance));
    
      settleTransactions.push({
        sender: minPaid.id,
        receiver: maxPaid.id,
        payAmount,
      });
      splitBalances[maxPaid.id].balance -= payAmount;
      splitBalances[minPaid.id].balance += payAmount;
    }
    

検証

  • 以下のテストデータで検証
    • userAがスーパーで、3人分の計1,200円を払った(Aが800円立替え)
    • userBがコンビニで、userAとuserBの2人分の計500円を払った(Bが250円立替え)
    • userBがドラッグストアで、userCの1000円を払った(Cが1,000円立替え)
    [
        {
            "amount": 1200,
            "category": "食費",
            "paidFor": [
                "userA",
                "userB",
                "userC"
            ],
            "content": "スーパー",
            "type": "expense",
            "date": "2025-03-25",
            "userId": "userB",
            "payer": "userA",
            "id": "transaction001"
        },
        {
            "paidFor": [
                "userA",
                "userB"
            ],
            "category": "食費",
            "type": "expense",
            "userId": "userB",
            "amount": 500,
            "content": "コンビニ",
            "date": "2025-03-25",
            "payer": "userA",
            "id": "transaction002"
        },
        {
            "date": "2025-03-25",
            "type": "expense",
            "category": "日用品",
            "content": "ドラッグストア",
            "payer": "userB",
            "paidFor": [
                "userC"
            ],
            "amount": 1000,
            "userId": "userB",
            "id": "transaction003"
        }
    ]
  • 計算結果は、以下となった
    • userCがuserBに850円支払う
    • userCがuserAに550円支払う
    [
       {
           "sender": "userC",
           "receiver": "userB",
           "payAmount": 850
       },
       {
           "sender": "userC",
           "receiver": "userA",
           "payAmount": 550
       }
    ]

userBがuserAに400円、userCがuserAに400円、userAがuserBに250円、userCがuserBに1000円返して・・・とやり取りをしなくとも、この計算結果からuserCが2人に支払うことで、簡単に清算ができる!!

まとめ

reduceの使い方をもっと工夫すれば、オブジェクトをオブジェクト配列に変換する必要がないかも?

人数が増えるほど、取引が増えるほど、この計算は重宝する。
だが、この家計簿アプリは当面2人でしか使わない...

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?