背景
彼女と同棲を始めてから、お互いに家計を共有できるように、我が家では自作の収支管理アプリ(React×TypeScript×Firebase)を使っている。
普段の買い物は、共通口座に紐づけたクレジットカードを使って、月末にクレジットカードの利用明細をアップロードすることで、支出をアプリに記録している。
しかし、キャッシュレスが普及した今でも、現金のみしか扱っていない店舗があり、どちらかが立て替えて支払うことがある。現金払いの場合、アプリでは手入力するが、ここで立て替えたことを同時に記録し、最終的にどっちがいくら多く払っているかを計算し、清算まで行いたいと考えた。
検討
ふたりで清算を行うのであれば、各々が立て替えた金額を合計して2で割り、お互いで相殺して不足分を片方に支払うことで清算は済むので、本来難しいロジックは不要である。
しかし、せっかく検討するのであれば、2人以上の立替え清算もできるようにしたい。
(我々の家計簿アプリでは2人以上は使用しないが。)
また、どちらかがどちらかの支払いを立て替えることもあるだろう。(私の個人的な買い物で、たまたま私が現金を持っておらず、代わりに彼女に払ってもらう・・・など)
イメージとしては、彼女や友人と旅行の際によく使っていた、割り勘計算アプリ「Walica(ワリカ)」のような機能を実装したいと考えた。(便利なのでおすすめ)
Walicaが便利なところは、複数人で複数回の割り勘を行ったとしても、お金のやり取り回数を少なくして清算ができるところである。今回はこの清算ロジックを実装してみる。
清算ロジック
割り勘計算のロジックをGoogleで調べてみると、いろいろな計算方法が出てくるが、
今回は以下の記事を参考にさせてもらう。
「最も支払わなかった人が最も支払った人に払えるだけ払う → 債権を再計算して繰り返す」
- 全員の出費を算出(払い過ぎは正、払わなさすぎは負の数)
- 降順でソート(出費過多が先頭)
- リストの最後(最大債務者, 出費=L)がリストの最初(最大債権者, F)に支払ってバランス(負債)を再計算
- 全員のバランスが 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
を空にすることで、立替払いか否かを判断する。
清算画面
レポート機能の中に、清算画面を追加する。
誰が誰にいくら払えば良いかを表示し、ユーザごとに支払うべき金額、貰うべき金額を表示する。内訳を選択すると、そのユーザの立替えた(立て替えてもらった)金額と取引、支払った金額を表示する。
清算ロジックの実装
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人でしか使わない...
参考