
Day 7 談的是身份。員編與 LINE 最後都必須收斂到同一個 canonical user。身份一旦開始穩定,另一個問題就變得更明顯:這個人現在到底還有多少錢?
一開始,這題看起來很簡單。User 上放一個 balance,畫面把它顯示出來;下單就扣掉,儲值就加回去。如果系統只是表單,這種做法完全合理。
但只要開始出現儲值、下單扣款、人工調整、舊資料補正與歷史查詢,「目前餘額」就不再只是 User 上的一個欄位。接下來要回答的,已經從「現在是多少?」變成「為什麼現在是這個數字?」
一旦系統開始替使用者保管餘額,資料責任就從保存一個數字,升級成保存這個數字如何形成的證據。
最簡單的模型會直接保存 users.balance。它很適合快速回答目前餘額。
但當系統開始記錄每一筆儲值與扣款,又會出現另一套資料:TOPUP、ORDER、ADJUSTMENT,以及每筆異動之後的 balance_after。
兩份資料同時存在沒有問題;麻煩的是兩邊不一致時,到底要相信誰?
先用一個簡化例子:假設 users.balance 顯示 800,但最新一筆交易紀錄的 balance_after 是 700。這只是用來說明資料權威衝突,不是在描述某一筆 Production Incident。這時候不能只說「有一個數字錯了,改成一樣就好」。因為必須先回答:哪一份才有權定義現在?
如果這件事沒有先決定,每個 API、每個畫面、每個管理功能,都可能各自選一個自己相信的來源。那麼餘額就不再是一個資料欄位,而是多個系統元件之間的意見。
這套系統最後形成了一個很重要的規則:只要已經存在有順序的 Ledger,最新一筆 sequenced ledger 就是餘額的 canonical authority。
users.balance 則保留成 mirror / fallback;當 sequenced ledger 已存在時,它不再是主要權威。
| 資料 | 主要責任 |
|---|---|
| Ledger | 解釋餘額如何一路變成現在這個數字 |
| Latest sequenced ledger | 回答 canonical current balance |
| users.balance | 快速 mirror;沒有 sequenced ledger 時才 fallback |
| Diagnostics | 找出歷史 discontinuity、異常與對帳問題 |
這一步是在正式回答:到底誰有資格說「這是目前餘額」。
一開始很容易想到:每筆交易都有 timestamp,照時間排序不就好了?如果所有資料永遠由同一條乾淨流程寫入,可能真的夠用。
但真實系統還會碰到舊資料匯入、補寫、修正與不同流程寫入。created_at 和帳務實際順序,不一定永遠是同一件事。
因此 Ledger 後來不只需要「有哪些交易」,還需要明確 sequence。每一筆都能留下 balance_before、amount 與 balance_after,並檢查 balance_before + amount = balance_after;下一筆也應該能接上前一筆的 balance_after。
當這條鏈成立時,「現在有多少」不再是孤立數字,而是一條可以往回追的路。
這套便當系統不是從完整帳務模型開始。早期先有使用者餘額、訂單、舊系統資料與儲值紀錄,後來才逐步把 Ledger 與 sequence 補完整。
因此在整理歷史資料時,真的會遇到前一筆 balance_after 與下一筆 balance_before 對不起來的 Ledger chain discontinuity。
這時最危險的直覺,是把全部 amount 重新加總,算出一個看起來更合理的答案,再直接蓋回去。
問題是,歷史可能包含舊系統 cutoff、技術性 restore、人工 adjustment、migration 中繼狀態,甚至某段資料本來就缺少完整起點。重新算得出另一個數字,不代表那個數字自然就比既有帳務狀態更有權威。
所以系統採取比較保守的做法:Diagnostics 可以指出歷史 chain 有問題,但不能偷偷用重新計算的結果取代 canonical closing balance。
也就是把兩件事分開:一,現在帳上認定多少;二,歷史鏈條是否完全自洽。兩者都要回答,但不能混成一題。
Ledger 裡還有另一個很容易踩坑的地方。假設看到 ADJUSTMENT +800,直覺會把它理解成「使用者增加了 800 元」。如果月報直接把它算進本月儲值,數字看起來也很合理。
但某些 Adjustment 的用途,是補回 legacy cutoff 時缺少的 balance baseline,也就是 technical restore。
如果這種 restore 被當成一般 top-up,current balance 也許仍然正確,但「這個月加了多少錢」就會被放大。
所以 Ledger 不只有 amount,還需要 semantic。這套系統目前可以直接驗證的例子包括:TOPUP 是儲值、ORDER 是訂單扣款、ADJUSTMENT 則可能是校正。未來若加入退款或其他反向異動,也應該保留自己的交易語意,而不是只靠金額正負判斷。技術性 restore 更不能直接用正數就推論成一般儲值。
正負號只告訴你數學方向,沒有告訴你這筆錢為什麼出現。
這次 Ledger 整理最後留下了一個很實用的三層分工。

Transaction Fact 保存異動事實;Balance Projection 由 latest sequenced ledger 定義 canonical balance;Presentation / Summary 再把同一份事實轉成使用者可理解的語意。
保存 amount、balance_before、balance_after、sequence、type 與 reference,回答「發生了什麼」。
由 latest sequenced ledger 得到 current balance,回答「現在是多少」。
把原始交易翻成「本月儲值、本月消費、歷史餘額校正」等可理解語意,回答「要怎麼讓使用者理解」。
如果三層混在一起,Frontend 很容易只看到正數就自己把 technical restore 翻成「儲值 +800」。當責任拆開後,畫面就不必重新猜這筆交易的語意。
一般使用者畫面可能只需要目前餘額與可讀的交易描述。但 Debug 或管理場景還需要 sequence、balance_before、balance_after、reconciliation、discontinuity 與來源資訊。
這些 diagnostics 不一定全部直接暴露給一般前端,可是系統本身必須保留足夠 Evidence,讓問題發生時能追。
這也是金額資料和普通表單欄位最大的差別之一:除了答案,還要留下答案是怎麼來的。
這套便當系統管理的是內部預付餘額,不是銀行核心,也不是總帳系統。
這裡需要的,是一個清楚的 balance authority、可追溯的異動順序、保留原事實的 correction,以及能定位不一致的 diagnostics。
如果系統只做單次付款,並由外部金流完成、自己不保存內部餘額,可能根本不需要這套 Ledger。反過來,只要開始承擔「儲值 → 保留餘額 → 多次扣款 → 調整 → 歷史查詢」,只存一個 balance 通常就不夠了。
如果最後只能說「大概是某次儲值沒同步到」,那就代表系統其實沒有足夠的帳務可觀察性。
如果把這段演進只寫成「以前 users.balance,後來新增 balance_ledger」,會漏掉核心:資料責任已經變了。
這次的演進是:一個數字 → 一串交易 → 一個有順序的 Ledger → 明確的 Balance Authority → 可追溯的 Diagnostics。
對普通表單來說,把值更新成最新版可能就夠了;但對餘額來說,系統還必須回答「怎麼變成這樣」以及「如果兩份資料不同,誰才算數」。這也是 Day 8 從欄位更新走向 Ledger 的原因。
這不是純概念示範,而是一套持續開發中的便當訂購系統。文章著重「為什麼」,GitHub 保留實際程式碼、文件與演進紀錄。
GitHub:https://github.com/henryfir456/bento-order-app
Ledger 解決的是:金額如何一路變成現在的狀態。
但系統裡還有另一種資料,也不能永遠只保留最新版。例如今天看到的一份菜單,下個月價格改了之後,過去訂單到底應該看到哪一版?
下一篇會繼續沿著「歷史不能被現在覆蓋」這件事往下走,但主角會從金額換成另一個每天都看得到的東西:菜單。