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

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

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

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

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

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

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

https://walica.jp/

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

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

https://uechi.io/blog/split-bill/?fbclid=IwAR2Rki4umS-IgSWivLCXKi2m02n3JG0wVhgxygdBWCnvvYa9FzS76GU4AuM

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

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

## 自作アプリの仕様

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

### DB

DBにはFirebaseのFirestoreを使用しており、以下のフィールドのドキュメントを取引ごとに保存する。
支払った人を`payer`、立て替えてもらった人を配列の`paidFor`に保持する。
```java
{
  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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4014781/e0243a8c-2352-495d-866e-63461c8f9158.png)

#### 清算画面

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

![ui2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4014781/b26b0eab-cb9b-495f-87c3-407cdf693116.png)

## 清算ロジックの実装

#### 1. 全員の出費を算出（払い過ぎは正、払わなさすぎは負の数）
* 全取引が格納されている配列（`transactions`）から、立替えが発生している取引のみを抽出し、フィルターした配列をreduceでユーザごとの残高、負債、支払い合計を計算する。
  
    ```javascript
    // ユーザごとの残高、負債、支払い合計を算出
    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.で取得したユーザごとの残高から、最大値と最小値を取得する
    ```javascript
    // 各ユーザの貸借を計算する
    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）に支払ってバランス(負債)を再計算
* 最も残高が多いユーザの残高を、最も少ないユーザの残高に加算し、最も多いユーザの残高から減算する。
    ```javascript
    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.の処理は関数とした）
    ```javascript
    // 各ユーザの貸借を計算する
    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人でしか使わない...

## 参考

https://qiita.com/MasashiHamaguchi/items/0348082984b8c94ca581

https://uechi.io/blog/split-bill/?fbclid=IwAR2Rki4umS-IgSWivLCXKi2m02n3JG0wVhgxygdBWCnvvYa9FzS76GU4AuM