dimesum

ホーム / ブログ / エンジニアリング

エンジニアリング

割り勘の残高を正確に保つ追記専用の台帳

· 読了 約6分 ·

残高は捨てて作り直せる投影であるべきで、それを安全にするのが合計ゼロの仕訳、訂正を追記する編集、コミット順に配信するアウトボックスです

加算していく残高はずれていく残高です。Dimesumは残高を加算しません。すべての支出は、明細の合計がちょうどゼロになる複式簿記の仕訳を追記し、グループ画面に出る数字はそれらの明細に対する投影で、コマンド1つで削除して作り直せます。

この主張は検証できます。お金が動く唯一の手段が追記専用の仕訳であれば、間違った残高は謎ではなくなり、実行できるクエリになります。

残高は加算する数字ではなく投影です

Dimesumの台帳は3つのテーブルを持ちます。journalspostings、そしてグループ・メンバー・通貨をキーとするbalances投影です。真実を担うのは明細です。明細は、メンバーがグループにお金を入れたときに正、価値を消費したときに負となるため、残高は素朴なSUM(amount_minor)です。投影は速度のために存在し、権威のためではありません。仕訳自身のトランザクションの中で書かれ、明細が完全であることが投影を使い捨て可能に保ちます。

2つの層が同じゼロを表明し、どちらも単独では足りません

Goでは、buildPostingsは明細の集合の合計がゼロにならない限りトランザクションを開きません。Postgresでは、遅延制約トリガーがコミット時に仕訳ごとにSUM(amount_minor) = 0を再チェックします。明細は1行ずつ挿入され、行ごとのチェックではどの仕訳も最初の明細で弾かれてしまうからです。表明は計算側のバグを捕まえます。トリガーはそれを一度も呼ばなかった書き込み側を捕まえます。

3つ目の層は権限です。UPDATEDELETETRUNCATEは両方のテーブルでledger_appロールから剥奪されているため、バグのあるコードは試みても履歴を書き換えられません。

追記専用台帳が不可能にすること、加算式の残高テーブルとの対比
バグの種類加算式の残高テーブル追記専用台帳
債務者に請求、支払者に貸方が立たない気づかれず永遠に誤り合計ゼロのチェックが失敗し、書き込みが拒否される
分担が変わっても合計が変わらない残高が静かにずれる不均衡な仕訳はコミットできない
「残高が412円なのはなぜ」答えられないすべての1円が仕訳までたどれる
本番でのホットフィックス追跡されない変更唯一の経路は監査された新しい仕訳

編集はUPDATEではなく打ち消しを追記します

支出の編集は古いバージョンに何も上書きしません。台帳は1つのexpense.amendedイベントを消費し、1つのトランザクションで2つの仕訳を追記します。差し替えられるバージョンを明細ごとに正確に打ち消すEXPENSE_REVERSAL、続いて新しいバージョンのための新鮮なEXPENSEです。削除は打ち消しの後で止まります。

1つのトランザクションであることは、2つの仕訳と同じくらい重要です。もし打ち消しが単独でコミットされれば、グループはまだ負っている支出について一瞬だけ何も負っていないことになります。

支出の編集は、バージョン1を打ち消すEXPENSE_REVERSALと、バージョン2のための新しいEXPENSEを1つのトランザクションで追記します。balances投影はすべての明細にわたる合計です。 1つのトランザクション EXPENSE expense:7c1:v1 明細4件 sum = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal 全明細を打ち消し sum = 0 EXPENSE expense:7c1:v2 明細5件 sum = 0 balances投影 = メンバーごと・通貨ごとの SUM(postings) 使い捨て可能: evenly rebuild-balances がこれを消去し、すべての明細を再生する
編集は追記します。打ち消しは自分が打ち消すバージョンを指し、差し替え版がその隣に追記されます。

どちらの冪等キーも、生成されるのではなくバージョンから導かれます。バージョンにはexpense:<id>:v<n>、打ち消しには前のバージョンのキーに:reversalを足したものです。journals.idempotency_keyはUNIQUEなので、再配信されたイベントは自分の仕訳がすでにあることを見つけ、何もしません。導出こそが少なくとも1回の配信を安全にします。再試行は最初の試行が使ったキーを計算します。

コミット順のウォーターマークが訂正を支出の後ろに保ちます

Dimesumが公開するすべてのイベントは、業務上の書き込みと同じトランザクションでoutboxテーブルに書かれます。支出がコミットすれば、通知は存在します。ロールバックすれば、通知もロールバックします。台帳は存在しない支出について知らされることがありません。

配信順はより難しい方の半分です。idはUUIDv7で時刻順に並びますが、それが符号化するのはidが生成された時点であって、そのトランザクションがコミットした時点ではありません。id順に読むリレーは、より早いidを取って後からコミットしたトランザクションを飛び越えてしまい、訂正がそれが訂正する支出を追い越します。

業務上の書き込みとそのoutbox行は1つのトランザクションでコミットします。リレーはその後、挿入したトランザクションidがpg_snapshot_xminウォーターマークより下のoutbox行だけを、トランザクションid順に配信します。 1つのトランザクション expense 行を挿入 outbox 行を挿入 トピック id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 書き込み 進行中 イベントバス 台帳 コンシューマ pg_snapshot_xmin(pg_current_snapshot())。その上の行は、より古いものが実行中でない状態でコミットした トランザクションが書いたものです。下の行は次のパスを待ちます。 id順に並べると 019a-1a8 が最初に配信され、訂正がそれが訂正する expense より先に届き かねません。inserted_xid 順ではそれは起きません。後のイベントは後の xid を持ちます。
outbox 行は業務上の書き込みとともにコミットし、リレーはウォーターマークより下をトランザクション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を再利用する貸倒れは、「実際にいくら払われたか」というすべてのクエリを静かに誤らせるからです。

Dimesumの台帳にある5つの仕訳タイプと、それぞれが追記する明細
仕訳タイプ追記される場面明細
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_FORGIVENWRITE_OFF_GRANTEDと名付けられ、支払いのクエリが正しいままになります。