主控台 老闆報告 Specs 索引 金流架構重構
#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 11 (cleanup deprecated) 等老闆 dogfood 確認新 ledger 數字正確後再做。
✅ 老闆已答 6 個 Q(2026-05-27)
  1. 歷史資料:「目前還在開發階段,用最簡單方式」→ 採方案 A(留著不動),production 還沒上時直接清 dev 環境。報表加 WHERE business_date >= 上線日 分水嶺。
  2. 清算戶:「照業界標準」→ 每個 processor 一個 clearing account,第一次設定該 processor 時自動 seed 對應 asset。
  3. payout 對帳:「自動比較好,目前要做藍新和 TapPay,TapPay 列未來」→ Phase 7 自動對帳先做藍新(NewebPay webhook),TapPay 留 framework + UI placeholder,實作 defer 等老闆完成審核。
  4. 半付款失敗:「業界怎麼做就怎麼做」→ 業界(Stripe / Square)做法 = 失敗的 payment 留 row(status=failed)但不寫 asset_transaction,只有 status='completed' 才 fire ledger。重試成功才寫一筆。
  5. 老闆改關帳數字:「照建議」→ 當月可改 / 跨月封存 / 每次改填理由 → daily_close_edits。改完只動 daily_closes 的 expected/actual 欄位,既有 asset_transactions 不動(若要調整 balance,寫新一筆 manual_adjustment 並 link 到該 close)。
  6. 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 個成熟系統,都用同一套模式:

§2. 新架構設計

2.1 核心原則

  1. 每個金流事件都是 asset_transaction(event-sourced) — 結帳 / 退款 / AdHoc / 第三方 payout / 錢箱 reset / 盤點差異,全部一筆一筆寫
  2. 關帳完全不寫金流 — 只寫 daily_closes 盤點紀錄。dailyCloseRevenue type 廢除
  3. 退款必 link 原 payment — `asset_transactions.refund_of_id` FK 指回原 transaction
  4. Clearing account per processor — TapPay / LINE Pay / 街口 / ezPay 各一個清算戶
  5. 關帳 = 期末盤點 — 員工點現金 → 比對 ledger 算的「應有現金」 → 差異走 cash_shortage / cash_overage 並填理由

2.2 帳戶結構(餐廳建立時自動 seed)

Asset 類型name用途
cash現金 / 錢箱POS 收現直接 +,退現 -;關帳盤點對標的
bank銀行存款清算戶 payout 進這裡;轉帳目的地
clearing清算 - TapPay刷卡 / Apple Pay 結帳當下進,T+2 payout 轉銀行
clearing清算 - LINE PayLINE 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)
2payment_method → asset mapping 業界怎麼做? (我提建議) 業界:per-tender mapping table,老闆可在 UI 設定每個 method 對應的 asset。建議 ChefsMate seed 預設 mapping + 老闆可改
3T+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 時區處理

§5. 改動範圍 — Code Audit 結果

File現狀改成
PaymentService.processCashPayment fire recordCashIncomeother_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 個方案,老闆選一個:

方案 B:跑 backfill,把過去 dailyCloseRevenue 拆成 per-payment

§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. 已知風險

§10. 相關文件

Spec 建立 2026-05-25 · 等老闆答 6 個 Q 後 Phase 0 開做 · 預估 ~15-17 天