#W13 · spec · 老闆已答 6Q · 動工中
金流架構重構 — 結帳即記帳 / 關帳只盤點
老闆 2026-05-25 抓出根因:「關帳時補記帳 + AdHoc 即時記帳」雙軌制造成
source_marker='adhoc_synced_balance' hack + cashRefunds 反推錯亂。
提案改成業界標準「event-sourced 即時記帳 + 期末盤點」單軌制。
Audit 完現有 code + 業界 (Square / Toast / R365 / Stripe) research 後,給出完整重構 spec。
📦 進度 (2026-05-27)
✅ Phase 0-9 全 ship:
- Phase 0 DB migration · 8c284a61
- Phase 1-5 Ledger orchestrator + PaymentService/RefundService/AdHoc/SettlementService 切換 · 134a9bbf
- Phase 6 補關帳清掉 dailyCloseRevenue · b25a77bc
- Phase 7 NewebPay payout 自動對帳 webhook + UI + Phase 8 關帳編輯 trail UI · 982857bf
- Phase 9 報表 audit:已 verify Payment 表 aggregation 在 dual-write 下數字正確,無需改
⏸️ Phase 11 (cleanup deprecated) 等老闆 dogfood 確認新 ledger 數字正確後再做。
✅ 老闆已答 6 個 Q(2026-05-27)
- 歷史資料:「目前還在開發階段,用最簡單方式」→ 採方案 A(留著不動),production 還沒上時直接清 dev 環境。報表加
WHERE business_date >= 上線日 分水嶺。
- 清算戶:「照業界標準」→ 每個 processor 一個 clearing account,第一次設定該 processor 時自動 seed 對應 asset。
- payout 對帳:「自動比較好,目前要做藍新和 TapPay,TapPay 列未來」→ Phase 7 自動對帳先做藍新(NewebPay webhook),TapPay 留 framework + UI placeholder,實作 defer 等老闆完成審核。
- 半付款失敗:「業界怎麼做就怎麼做」→ 業界(Stripe / Square)做法 = 失敗的 payment 留 row(status=failed)但不寫 asset_transaction,只有 status='completed' 才 fire ledger。重試成功才寫一筆。
- 老闆改關帳數字:「照建議」→ 當月可改 / 跨月封存 / 每次改填理由 → daily_close_edits。改完只動 daily_closes 的 expected/actual 欄位,既有 asset_transactions 不動(若要調整 balance,寫新一筆 manual_adjustment 並 link 到該 close)。
- dual-write 過渡期:「照建議,測試方案放老闆報告」→ dual-write 1-2 個營業日,完整測試方案(8 個 scenarios + 預期 balance 值)放老闆報告 dogfood 區。
§1. 為什麼要改
老闆原話:「關帳是不是應該不要影響到記帳?所有單子結帳的當下就會直接記帳,
退款的時候找出當初那筆帳來退款,再記這一筆退款的帳。關帳的用意只是讓員工點清楚錢箱裡面的錢,
還有在關帳的時候完成錢箱 reset...這樣是不是就不會有重複記帳的問題?」
1.1 現狀:雙軌制 + 不一致
| 事件 | 現在發生什麼 | 狀態 |
| 現金結帳 | PaymentService → PettyCashService.recordCashIncome → 寫 asset_transaction(other_income) | 即時記帳 ✅ |
| 刷卡 / TapPay / LINE Pay / Apple Pay | 只更新 Payment.status='completed',不寫 asset_transaction | 沒記 ❌ |
| 退款(任何方式) | 只更新 Payment.refundedAt / refundAmount,不寫 asset_transaction | 沒記 ❌ |
| AdHoc 臨時收支 | AdHocTransactionSheet 直接 INSERT + 加 source_marker='adhoc_synced_balance' | 即時記帳 ✅ |
| 關帳(final close) | SettlementSheet 寫一筆 dailyCloseRevenue = totalRevenue + AssetTransaction(電子付款其實第一次被記) | 補記帳 ⚠️ |
根因:因為現金「結帳時也記了 + 關帳時 dailyCloseRevenue 又會 sum 一次」,所以
為了避免重複,寫了 source_marker='adhoc_synced_balance' hack 讓關帳排除這筆。
然後 expectedCash 公式變成 startingCash + cashTotal - cashRefunds + adHocAdjustment(unmarked) —
cashRefunds 又因為要倒推所以變反推公式(= startingCash + cashTotal - expectedCash),
最後 UI 顯示時 AdHoc 沒列在明細 + cashRefunds 數字錯亂。這 2 個 UI bug 是症狀,不是病因。
1.2 業界怎麼做 — research 結論
Audit Square / Toast / Restaurant365 / Stripe 4 個成熟系統,都用同一套模式:
- Event-sourced ledger:每筆 payment / refund / fee / payout 都是獨立 immutable transaction row。Stripe 的
balance_transactions 就是這樣 — "replay 全部到 T 時刻 = T 時刻的 balance"
- Per-tender mapping:每種付款方式(cash / 信用卡 / LINE Pay / 街口)在系統內 mapping 到一個 asset account。**不**按 processor 分,按 tender 分
- Clearing account 模式:刷卡不直接記「銀行 +1000」,而是記「清算戶 - TapPay +1000」,等 T+2 payout 到帳才轉「銀行 +980 + 手續費 +20 − 清算戶 1000」
- 關帳純盤點:不寫 revenue summary 進 ledger,只記「實點現金 vs 系統算的應有現金 → 差異列 cash short/over」
§2. 新架構設計
2.1 核心原則
- 每個金流事件都是 asset_transaction(event-sourced) — 結帳 / 退款 / AdHoc / 第三方 payout / 錢箱 reset / 盤點差異,全部一筆一筆寫
- 關帳完全不寫金流 — 只寫 daily_closes 盤點紀錄。
dailyCloseRevenue type 廢除
- 退款必 link 原 payment — `asset_transactions.refund_of_id` FK 指回原 transaction
- Clearing account per processor — TapPay / LINE Pay / 街口 / ezPay 各一個清算戶
- 關帳 = 期末盤點 — 員工點現金 → 比對 ledger 算的「應有現金」 → 差異走
cash_shortage / cash_overage 並填理由
2.2 帳戶結構(餐廳建立時自動 seed)
| Asset 類型 | name | 用途 |
| cash | 現金 / 錢箱 | POS 收現直接 +,退現 -;關帳盤點對標的 |
| bank | 銀行存款 | 清算戶 payout 進這裡;轉帳目的地 |
| clearing | 清算 - TapPay | 刷卡 / Apple Pay 結帳當下進,T+2 payout 轉銀行 |
| clearing | 清算 - LINE Pay | LINE Pay 結帳當下進 |
| clearing | 清算 - 街口 | 街口結帳當下進 |
| clearing | 清算 - ezPay / 藍新 | 藍新結帳當下進 |
| petty_cash | 零用金 | (可能跟現金同一個,看老闆設定) |
2.3 完整水流圖
場景 A:客人現金結 $500
asset_transactions: [
{ type: 'sale_cash', amount: +500, asset: 現金, payment_id: P1, business_date: 2026-05-25 }
]
→ 現金 +500
場景 B:客人 TapPay 結 $1000(T+2 入帳)
Day 0: asset_transactions: [
{ type: 'sale_card', amount: +1000, asset: 清算-TapPay, payment_id: P2, business_date: 2026-05-25 }
]
→ 清算-TapPay +1000
Day +2: 老闆收到 TapPay payout $970(扣 $30 手續費)
asset_transactions: [
{ type: 'processor_payout', amount: +970, asset: 銀行, processor: 'tappay', payout_date: 2026-05-27 },
{ type: 'processor_fee', amount: -30, asset: 清算-TapPay, processor: 'tappay', payout_date: 2026-05-27 },
{ type: 'processor_settlement', amount: -1000, asset: 清算-TapPay, processor: 'tappay', payout_date: 2026-05-27 }
]
→ 銀行 +970, 清算-TapPay 歸 0(原本 +1000 - 30 - 970 = 0)
場景 C:退款 P1 全額 $500(現金)
asset_transactions: [
{ type: 'refund_cash', amount: -500, asset: 現金,
refund_of_id: ,
payment_id: P1, business_date: 2026-05-25 }
]
→ 現金 -500
場景 D:臨時支出 $200 買蔥(從零用金)
asset_transactions: [
{ type: 'adhoc_expense', amount: -200, asset: 零用金, note: '買蔥',
created_by: , business_date: 2026-05-25 }
]
→ 零用金 -200
場景 E:關帳 — 員工數錢
ledger 算的「應有現金」 = 期初零用金 +sum(現金 type 的 asset_transactions 當天)
= 3000 + 500 - 500 - 200 = 2800
員工實點 = 2750
差異 = -50
daily_closes 寫一筆:
{ close_date: 2026-05-25, close_type: 'final', closed_by: 員工A,
closed_at: 2026-05-25T22:30:00+08:00,
expected_cash: 2800, actual_cash: 2750, cash_difference: -50,
cash_shortage_reason: '找錯 50 給客人 #1234',
starting_cash_for_next_day: 3000,
starting_cash_reset_adjustment: +250 (從銀行補 250 進錢箱 → 變回 3000)
}
順便寫(if starting_cash 有 reset):
asset_transactions: [
{ type: 'cash_shortage', amount: -50, asset: 現金, reason: '找錯 50 給客人 #1234' },
{ type: 'cash_drawer_top_up', amount: +250, asset: 現金,
related_close_id: , note: '從銀行補錢箱回 3000' },
{ type: 'cash_drawer_top_up_offset', amount: -250, asset: 銀行,
related_close_id: }
]
→ 現金 -50 +250 = 2750 + 250 = 3000 (對標下一個營業日期初)
→ 銀行 -250
§3. 老闆已答 6 個議題
| # | 議題 | 老闆答覆 | 實作 impact |
| 1 | 關帳歷史紀錄要記什麼? |
誰 / 何時 / 轉了多少錢去哪個帳戶 / 某天有沒有關帳(補關帳系統比對) / 有沒有 reset 零用金。老闆可修改數字(處理犯錯) |
daily_closes 已有 closed_by / closed_at / cash_amount / revenue_asset_id / cash_difference / cash_shortage_reason / actual_cash。需新增:starting_cash_reset_adjustment + close_edit_audit(老闆編輯紀錄 trail) |
| 2 | payment_method → asset mapping 業界怎麼做? |
(我提建議) |
業界:per-tender mapping table,老闆可在 UI 設定每個 method 對應的 asset。建議 ChefsMate seed 預設 mapping + 老闆可改 |
| 3 | T+2 第三方金流業界怎麼做? |
(我提建議) |
業界:clearing account 模式。建議 ChefsMate 採用 — 每個 processor 一個清算戶,payout 到帳走 reconciliation orchestrator。對小餐廳隱藏複雜度(UI 只顯示「TapPay 入帳 $970 / 手續費 $30」一個 confirm 按鈕) |
| 4 | 盤點差異 |
記現金短溢,員工列明理由 |
已有 asset_transactions.transaction_type='cash_shortage' + daily_closes.cash_shortage_reason。新增 cash_overage type + 強制填理由 UI gate |
| 5 | 退款找原單 |
退款當下找原單退並記錄 |
新增 asset_transactions.refund_of_id FK + UI 顯示退款時的原單卡片 |
| 6 | 跨日訂單 |
關帳紀錄當下時間 + 關的這筆帳是哪一天的帳。時區小心對齊 |
新增 business_date DATE 欄位到 payments / asset_transactions / daily_closes(目前是 query-time 算)。所有 timestamp 存 timestamptz(UTC),所有 business_date 用餐廳時區算(Asia/Taipei) |
§4. DB Schema 變更
4.1 asset_transactions 表擴充
-- 退款 link
ALTER TABLE asset_transactions ADD COLUMN refund_of_id UUID REFERENCES asset_transactions(id);
CREATE INDEX idx_asset_tx_refund_of ON asset_transactions(refund_of_id) WHERE refund_of_id IS NOT NULL;
-- 業務日期(query 不必再 join business hours 算)
ALTER TABLE asset_transactions ADD COLUMN business_date DATE;
CREATE INDEX idx_asset_tx_business_date ON asset_transactions(restaurant_id, business_date);
-- 連回 payment(沒有 FK 因為訂金 / AdHoc 沒對應 payment)
ALTER TABLE asset_transactions ADD COLUMN payment_id UUID;
-- 連回關帳紀錄(對於關帳當下產生的 cash_drawer 操作)
ALTER TABLE asset_transactions ADD COLUMN daily_close_id UUID REFERENCES daily_closes(id);
-- 第三方金流 payout 追蹤
ALTER TABLE asset_transactions ADD COLUMN processor TEXT;
-- 'tappay' / 'linepay' / 'jkopay' / 'newebpay' / 'ezpay' / NULL(現金 / AdHoc)
ALTER TABLE asset_transactions ADD COLUMN processor_payout_id TEXT;
-- payout batch id 用來對帳
-- 新 transaction_type values:
-- 'sale_cash' — 現金結帳收入(取代既有 other_income 用於 payment)
-- 'sale_card' — 刷卡 / 行動支付收入(寫到 clearing)
-- 'refund_cash' — 現金退款
-- 'refund_card' — 刷卡退款
-- 'adhoc_income' — 臨時收入(取代既有 other_income for AdHoc)
-- 'adhoc_expense' — 臨時支出(取代既有 other_expense for AdHoc)
-- 'cash_shortage' — 盤點短缺(已存在)
-- 'cash_overage' — 盤點溢餘(新)
-- 'cash_drawer_top_up' — 錢箱補錢(從銀行或其他)
-- 'cash_drawer_top_up_offset' — 對沖,負數,從來源 asset 扣
-- 'processor_payout' — 第三方 payout 進銀行
-- 'processor_fee' — 第三方手續費
-- 'processor_settlement' — clearing 戶歸零
-- 廢除(不再寫,但歷史保留):
-- 'daily_close_revenue'
-- 'other_income' / 'other_expense'(細分成 sale_* / refund_* / adhoc_*)
4.2 daily_closes 表擴充
-- 是否 reset 零用金(老闆 Q1 已答)
ALTER TABLE daily_closes ADD COLUMN starting_cash_reset_amount NUMERIC;
ALTER TABLE daily_closes ADD COLUMN starting_cash_reset_from_asset_id UUID REFERENCES assets(id);
ALTER TABLE daily_closes ADD COLUMN starting_cash_reset_note TEXT;
-- 老闆編輯紀錄 trail(老闆 Q1:可修改但要 audit)
CREATE TABLE daily_close_edits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
daily_close_id UUID NOT NULL REFERENCES daily_closes(id) ON DELETE CASCADE,
edited_by TEXT NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
reason TEXT
);
4.3 payment_method → asset mapping 表
CREATE TABLE payment_method_asset_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE CASCADE,
payment_method TEXT NOT NULL, -- 'cash' / 'credit' / 'apple_pay' / 'linepay' / 'jkopay' / 'newebpay'
asset_id UUID NOT NULL REFERENCES assets(id),
is_clearing BOOLEAN NOT NULL DEFAULT FALSE,
processor TEXT, -- 'tappay' / 'linepay' / 'jkopay' / 'newebpay'(用於 payout reconciliation)
UNIQUE (restaurant_id, payment_method)
);
-- 餐廳建立時 seed 預設 mapping
4.4 時區處理
- 所有 timestamptz 存 UTC(Postgres 標準)
- business_date:`DATE` type,用餐廳時區(`restaurants.timezone` 已存在,default Asia/Taipei)算
- 計算公式:
business_date = (created_at AT TIME ZONE restaurant.timezone) >= 06:00 ? that_date : that_date - 1
- 提供 SQL function:
compute_business_date(ts TIMESTAMPTZ, restaurant_id UUID) RETURNS DATE
- 寫入時由 PaymentService / RefundService / AdHocService 自己算好 business_date(避免 trigger 跑跨表 query 慢)
- 跨年 / 夏令時 邊界 case 處理:餐廳跨年夜跨日營業 → business_date 跟營業日對齊(2026-12-31 凌晨 02:00 的訂單 business_date=2026-12-31 if closing_time_minutes > 1440)
§5. 改動範圍 — Code Audit 結果
| File | 現狀 | 改成 |
PaymentService.processCashPayment |
fire recordCashIncome 寫 other_income |
fire 新的 SaleLedgerOrchestrator,寫 sale_cash 進老闆 mapping 的 asset |
PaymentService.processElectronicPayment + processT2PPayment |
不寫 asset_transaction |
fire SaleLedgerOrchestrator,寫 sale_card 進對應 processor 的清算戶 |
PaymentService.refundPayment |
只改 Payment.refundedAt |
fire RefundLedgerOrchestrator,找原 asset_transaction → 寫 refund_* 反向 + 設 refund_of_id |
AdHocTransactionSheet |
直接寫 + source_marker |
改 call AdHocLedgerService,寫 adhoc_income/expense,不再加 marker |
SettlementService.computeSettlement |
expectedCash = startingCash + cashTotal - cashRefunds + adHocAdjustment(反推 cashRefunds) |
整段重寫:expectedCash = ledger 算的「現金 asset 在 business_date 的應有 balance」(SUM where asset=cash AND business_date=target) |
SettlementSheet 寫 dailyCloseRevenue |
寫一筆 totalRevenue 進 revenue_asset |
整段移除。改成只寫 daily_closes 紀錄 + (若 reset 零用金)寫 cash_drawer_top_up 對沖 |
| fetchUnmarkedAdHocAdjustment / source_marker 機制 |
過濾 'adhoc_synced_balance' |
整段移除。不再需要 marker 區分,因為沒有重複記帳 |
| 補關帳邏輯 (#R20+ 系列) |
補寫 dailyCloseRevenue + asset_transaction |
大幅簡化:補關帳 = 只補 daily_closes 紀錄 + 員工填當下實點現金(若忘了當天數錢就填「無紀錄」) |
| POSReportService / AccountingOverviewViewModel |
讀 dailyCloseRevenue type 計算 revenue |
改 aggregate 所有 sale_* type |
| compute-settlement edge fn |
action=compute → 算 totalRevenue 從 orders/payments aggregate |
不變(已經是 aggregate 不是讀 dailyCloseRevenue),但 expectedCash 算法要改 |
§6. Phase Plan
| Phase | 內容 | 工時 | 風險 |
| 0 |
DB migration:加新欄位 / 新表,既有欄位保留(向後相容)。seed payment_method_asset_mappings 預設 mapping |
1 天 |
低 |
| 1 |
新增 SaleLedgerOrchestrator + RefundLedgerOrchestrator + AdHocLedgerService(per CLAUDE.md Gate/Primitive/Orchestrator 架構,含 unit tests) |
2 天 |
低 |
| 2 |
切換 PaymentService 結帳路徑改 call SaleLedgerOrchestrator。電子付款補上 asset_transaction 寫入。dual-write 機制(舊邏輯保留為 fallback) |
2 天 |
中 |
| 3 |
切換 RefundService 改 call RefundLedgerOrchestrator + 連 refund_of_id |
1 天 |
中 |
| 4 |
切換 AdHocTransactionSheet 改 call AdHocLedgerService。移除 source_marker 寫入(保留 read 兼容歷史資料) |
1 天 |
低 |
| 5 |
SettlementService 重寫 expectedCash 公式 = ledger aggregate。SettlementSheet 移除 dailyCloseRevenue 寫入。daily_closes 加 starting_cash_reset 三欄 |
2 天 |
高 — 影響 UI 顯示 + 補關帳 |
| 6 |
補關帳邏輯重寫:不再補寫 dailyCloseRevenue,只補 daily_closes 紀錄。提供「忘了數錢」的 fallback option |
1 天 |
中 |
| 7 |
老闆 UI:payment_method → asset mapping 設定頁(POS 設定 → 金流)。清算戶 payout 對帳 sheet |
2 天 |
低 |
| 8 |
關帳紀錄編輯 trail:老闆改數字要填理由 → 寫 daily_close_edits |
1 天 |
低 |
| 9 |
POSReportService / AccountingOverviewViewModel 改 aggregate sale_* / refund_* / adhoc_* types |
1 天 |
中 — 報表數字要對 |
| 10 |
歷史資料 migration(見 §7) |
1-3 天 |
視策略 |
| 11 |
dual-write 關掉,舊 path 刪除 + 回歸測試 |
1 天 |
中 |
| 合計 | ~15-17 天 | — |
§7. 歷史資料 migration 策略
2 個方案,老闆選一個:
方案 A:過去資料留著,新邏輯只 forward(推薦) 推薦
- 舊 dailyCloseRevenue / source_marker 紀錄保留不動,歷史報表照舊計算
- 新邏輯只 apply 到「上線日之後」的訂單
- 報表 query 加邏輯:
WHERE business_date >= '2026-06-01' use new types ELSE use old types
- 優點:0 風險不會壞掉歷史數字
- 缺點:程式碼有「分水嶺」邏輯 12 個月才能拿掉
方案 B:跑 backfill,把過去 dailyCloseRevenue 拆成 per-payment
- 寫 migration script:從 payments 表 reconstruct 每一筆 sale_* asset_transaction
- 把舊 dailyCloseRevenue rows mark as
type='legacy_summary' 不再列入 aggregation
- 優點:報表邏輯乾淨,沒有分水嶺
- 缺點:script 跑錯就要 restore from backup;refund 對應特別難 reconstruct(原 asset_transaction 不存在)
§8. 給老闆的剩餘決策題
Q1. 歷史資料 migration 走方案 A(留著)還是方案 B(backfill)?
建議方案 A — 風險最低,半年後再決定要不要 backfill。
Q2. clearing account(清算戶)要每個 processor 各一個,還是先合一個「卡片清算」?
業界都各分 — 因為每個 processor payout 排程 + 手續費不同。建議照業界做,但 seed 時只開「現金 + 銀行 + 卡片清算」3 個,老闆有開 LINE Pay / 街口 時才自動 seed 對應清算戶(避免 UI 一開始就 6 個 asset 嚇到老闆)。
Q3. 第三方 payout 對帳是手動還是自動?
第一版手動(老闆收到 TapPay 入帳簡訊後在 POS 點「TapPay 入帳 $970/手續費 $30/Confirm」),3 行 entry orchestrator 自動寫。第二版可接 TapPay webhook 自動拉 payout data(這要看 TapPay API 有沒有提供)。建議第一版先手動。
Q4. 「sell 結帳當下 = 即時記帳」,但結帳沒完成(客人付一半失敗)怎麼處理?
建議:asset_transaction 只在 payment.status='completed' 時才寫,失敗的 payment 不留 ledger row。失敗→重試成功只有最後成功那筆寫。
Q5. 老闆編輯關帳數字 — 限多久內可改?要不要二次確認?
建議:當月可改,跨月封存。每次改要填理由 → 寫 daily_close_edits trail。老闆改完後系統算的數字會 update,但歷史 ledger entry 不動(編輯只反映在 daily_closes 的 expected/actual 欄,不會 reverse 既有 asset_transactions)。
Q6. dual-write 階段(Phase 2-4)要跑多久?
建議 1-2 個營業日,dogfood 確認新 ledger 數字跟舊 dailyCloseRevenue 對得起來 → Phase 11 拆掉舊 path。
§9. 已知風險
- 歷史報表數字飄 — 若選方案 B backfill 失敗,可能影響過去 6 個月的營收報表
- 退款找不到原 asset_transaction — 上線前的 refund 沒對應原 row,refund_of_id=NULL,要在 UI 區別「歷史退款」
- 多裝置時序 — 兩台 POS 同時結 1 桌 → 2 個 asset_transaction,realtime 同步要 idempotent(payment_id UNIQUE 防雙寫)
- 第三方金流 webhook 重試 — TapPay payout webhook 可能重傳,需要 idempotency key(processor_payout_id UNIQUE)
- 跨年 / 夏令時 — Asia/Taipei 沒夏令時,但若未來支援其他時區的餐廳要小心 business_date 算法
- 小數精度 — NUMERIC 不能用 Double 操作,Swift 端要全程 Decimal
§10. 相關文件
Spec 建立 2026-05-25 · 等老闆答 6 個 Q 後 Phase 0 開做 · 預估 ~15-17 天