割り勘の残高を正確に保つ追記専用の台帳
残高は捨てて作り直せる投影であるべきで、それを安全にするのが合計ゼロの仕訳、訂正を追記する編集、コミット順に配信するアウトボックスです
加算していく残高はずれていく残高です。Dimesumは残高を加算しません。すべての支出は、明細の合計がちょうどゼロになる複式簿記の仕訳を追記し、グループ画面に出る数字はそれらの明細に対する投影で、コマンド1つで削除して作り直せます。
この主張は検証できます。お金が動く唯一の手段が追記専用の仕訳であれば、間違った残高は謎ではなくなり、実行できるクエリになります。
残高は加算する数字ではなく投影です
Dimesumの台帳は3つのテーブルを持ちます。journals、postings、そしてグループ・メンバー・通貨をキーとするbalances投影です。真実を担うのは明細です。明細は、メンバーがグループにお金を入れたときに正、価値を消費したときに負となるため、残高は素朴なSUM(amount_minor)です。投影は速度のために存在し、権威のためではありません。仕訳自身のトランザクションの中で書かれ、明細が完全であることが投影を使い捨て可能に保ちます。
2つの層が同じゼロを表明し、どちらも単独では足りません
Goでは、buildPostingsは明細の集合の合計がゼロにならない限りトランザクションを開きません。Postgresでは、遅延制約トリガーがコミット時に仕訳ごとにSUM(amount_minor) = 0を再チェックします。明細は1行ずつ挿入され、行ごとのチェックではどの仕訳も最初の明細で弾かれてしまうからです。表明は計算側のバグを捕まえます。トリガーはそれを一度も呼ばなかった書き込み側を捕まえます。
3つ目の層は権限です。UPDATE、DELETE、TRUNCATEは両方のテーブルでledger_appロールから剥奪されているため、バグのあるコードは試みても履歴を書き換えられません。
| バグの種類 | 加算式の残高テーブル | 追記専用台帳 |
|---|---|---|
| 債務者に請求、支払者に貸方が立たない | 気づかれず永遠に誤り | 合計ゼロのチェックが失敗し、書き込みが拒否される |
| 分担が変わっても合計が変わらない | 残高が静かにずれる | 不均衡な仕訳はコミットできない |
| 「残高が412円なのはなぜ」 | 答えられない | すべての1円が仕訳までたどれる |
| 本番でのホットフィックス | 追跡されない変更 | 唯一の経路は監査された新しい仕訳 |
編集はUPDATEではなく打ち消しを追記します
支出の編集は古いバージョンに何も上書きしません。台帳は1つのexpense.amendedイベントを消費し、1つのトランザクションで2つの仕訳を追記します。差し替えられるバージョンを明細ごとに正確に打ち消すEXPENSE_REVERSAL、続いて新しいバージョンのための新鮮なEXPENSEです。削除は打ち消しの後で止まります。
1つのトランザクションであることは、2つの仕訳と同じくらい重要です。もし打ち消しが単独でコミットされれば、グループはまだ負っている支出について一瞬だけ何も負っていないことになります。
どちらの冪等キーも、生成されるのではなくバージョンから導かれます。バージョンにはexpense:<id>:v<n>、打ち消しには前のバージョンのキーに:reversalを足したものです。journals.idempotency_keyはUNIQUEなので、再配信されたイベントは自分の仕訳がすでにあることを見つけ、何もしません。導出こそが少なくとも1回の配信を安全にします。再試行は最初の試行が使ったキーを計算します。
コミット順のウォーターマークが訂正を支出の後ろに保ちます
Dimesumが公開するすべてのイベントは、業務上の書き込みと同じトランザクションでoutboxテーブルに書かれます。支出がコミットすれば、通知は存在します。ロールバックすれば、通知もロールバックします。台帳は存在しない支出について知らされることがありません。
配信順はより難しい方の半分です。idはUUIDv7で時刻順に並びますが、それが符号化するのはidが生成された時点であって、そのトランザクションがコミットした時点ではありません。id順に読むリレーは、より早いidを取って後からコミットしたトランザクションを飛び越えてしまい、訂正がそれが訂正する支出を追い越します。
修正は1つのカラムと1つの述語です。各outbox行はinserted_xid xid8 DEFAULT pg_current_xact_id()を持ち、リレーはWHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot())の行だけを、そのxid順で読みます(PostgreSQLのトランザクションid関数を参照)。因果的に後のイベントは必ず後のxidを持ちます。存在するためには先の行を読まなければならなかったからです。
訂正のコンシューマはその順序を鵜呑みにしません。先行仕訳のない訂正は、まだ新しいうちは再配信され、2分の猶予を過ぎると人の対応のために待避されます。
金額はint64の最小通貨単位で、指数は常に2とは限りません
すべての金額はint64の最小通貨単位の個数とISO 4217コードなので、1,234.56ルピーは{Minor: 123456, Currency: "INR"}です。整数は規律ではなく仕組みとして正確です。表現できる値に半セントはないため、どの演算も静かに半セントを生み出せません。Pythonのサイドカーは自身のASTを読み、金額モジュールにfloatという語が現れればテストを失敗させます。
最小通貨単位が100分の1だと仮定することが、その下に潜む罠です。JPYにはまったくなく、KWDには小数点以下3桁があるため、主要単位の間で提示された比率を最小通貨単位に適用すると10のべき乗だけ誤ります。2,000円を0.58倍してみましょう。素朴な積は1160で、これは11.60ルピーと読めますが、答えは1,160です。
WRITE_OFFが5つ目の仕訳タイプなのは、その明細が精算のものと一致するからです
貸倒れは精算と同じ2つの明細を追記します。同じ金額だけ、債務者が上がり、債権者が下がります。SETTLEMENTに畳み込む案は、形が一致するというまさにその理由で却下されました。「Ashaがあなたに500円払った」と「あなたがAshaの500円を免除した」は別の事実で、両者を混同するフィードは、誰も払っていないのに誰かが払ったと表示してしまいます。
そこでWRITE_OFFは2026-08-21に仕訳タイプのCHECKへ加わり、独自の明細を持ちました。それらに名前を付けることが要点の半分でした。SETTLE_PAYを再利用する貸倒れは、「実際にいくら払われたか」というすべてのクエリを静かに誤らせるからです。
| 仕訳タイプ | 追記される場面 | 明細 |
|---|---|---|
EXPENSE | 作成、または新バージョンが差し替えるとき | PAID, SHARE |
EXPENSE_REVERSAL | 編集または削除 | 前の仕訳の明細を打ち消したもの |
SETTLEMENT | 返済が表明されるとき | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | 相手方が異議を唱えるとき | 両方の精算明細を打ち消したもの |
WRITE_OFF | 債権者が請求を放棄するとき | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
2つのサブコマンドが不変条件をcronジョブに変えます
evenly verify-ledgerはスキーマ全体で不変条件を再証明し、1件でも該当すれば非ゼロで終了します。その報告には4つのフィールドがあり、どれも空であることが期待されます。合計がゼロにならない仕訳、合計がゼロにならないグループ、再計算したSUM(postings)と食い違う投影行、そして人の対応を待つ待避された出来事です。すべてのスキャンは1つのrepeatable-readスナップショットを共有するため、検証の途中で着地した仕訳が不一致を捏造することはありません。
各スキャンはidだけでなく通貨でもグループ化し、それが避けるのは偽陰性です。500ルピーの明細とマイナス500円の明細は、クエリが通貨カラムを無視すると合計がゼロになるため、二重に壊れた台帳が正常に見えてしまいます。
evenly rebuild-balances [group-id]は修復であり訓練でもあります。投影を消去し、明細からすべての行を再計算し、ライブの書き込みと同じく、そのメンバーを最後に動かした仕訳を各行に刻みます。行ごとの一致が性質です。再構築とライブの投影が食い違うとき、正しいのは台帳です。
まず検証、次に再構築です。報告はずれているすべての行について保存された残高と再計算された残高を名指しし、再構築は保存値を上書きするため、先に再構築すると証拠を壊します。
残高を導出可能にすれば、ずれはクエリになります
追記専用台帳が2つ目の仕訳に値するのは、それを証明できる場合だけなので、それを必要とする機能より先に検証器と再構築を作りましょう。誰も実行していない再構築は、脱出口ではなく願望です。今週いちばんリスクの高いお金の投影を選び、元データからそれを再計算するクエリを書き、2つが食い違ったら自分を呼び出しましょう。
よくある質問
割り勘アプリの追記専用台帳とは何ですか?
追記専用台帳は、すべてのお金の出来事を合計がゼロになる仕訳の記録として残し、一度書いた仕訳を更新も削除もしません。Dimesumでは、支出、編集、精算、異議、貸倒れのそれぞれが新しい仕訳を追記します。残高は仕訳の合計から導き出されるため、すべての1円がそれを動かした出来事までたどれます。
追記専用台帳では編集した支出をどう扱いますか?
編集は1つのトランザクションで2つの仕訳を追記します。まず元の各明細を打ち消すEXPENSE_REVERSAL、続いて差し替え後の内容を持つ新しいEXPENSEです。何も書き換えられず、削除は打ち消しだけを追記します。どちらの仕訳も支出のバージョンから導いた冪等キーを取るため、再配信された出来事はすでにある仕訳を見つけて何も変えません。
金額を浮動小数点ではなく整数で保存するのはなぜですか?
整数の最小通貨単位は仕組みとして正確ですが、2進浮動小数点は0.1を正確に表現できず、グループの履歴を通じてずれていきます。Dimesumはすべての金額を最小通貨単位の個数を表すint64とISO 4217コードで保存します。指数は通貨から決まります。JPYには最小通貨単位がまったくないため、100分の1があると仮定すると100倍の誤りになります。
割り勘の残高がずれていないと確認する方法は?
evenly verify-ledgerを実行します。これはスキーマ全体で不変条件を再証明し、1件でも該当すれば非ゼロで終了します。合計がゼロにならない仕訳、合計がゼロにならないグループ、再計算したSUM(postings)と食い違う投影行、待避された出来事を報告します。毎晩cronで回し、壊れた投影はevenly rebuild-balancesで修復します。
貸倒れに専用の仕訳タイプが必要なのはなぜですか?
貸倒れは精算とまったく同じ明細を追記します。だからこそ仕訳タイプを分ける必要がありました。「Ashaがあなたに500円払った」と「あなたがAshaの500円を免除した」を区別するのはその言葉だけで、両者を混同するフィードは、誰も払っていないのに誰かが払ったと表示してしまいます。その明細はWRITE_OFF_FORGIVENとWRITE_OFF_GRANTEDと名付けられ、支払いのクエリが正しいままになります。
人気の記事
- 共有費用を編集するときに分担をやり直す理由読了 約5分
- 多通貨の割り勘に潜む6つの金額バグ読了 約6分
- 精算:グループの費用を少ない送金で清算する方法読了 約6分
- 取り分けなかった一皿がある割り勘の精算方法読了 約6分
- ルームシェアの家賃を公平に分ける完全ガイド読了 約6分