線稿索引所有 spec最後更新 2026-06-04
specs/order-money-model.md

訂單金流模型

一句話:訂單的「營運外殼」與「財務真相」徹底拆開——orders 只管預訂狀態,錢拆成**應收(charge lines)/收款計畫(schedules)/意圖(intents)/嘗試(attempts)/現金分類帳(transactions)/退款(refunds)/應付(payables)**七層。

定位:本檔是金流模型的單一設計入口:模型、核心公式、不變量、狀態機、關鍵流程。精確欄位以 code(packages/core/src/schema/business.ts)為準;做了/沒做以 roadmap 為準;對標 Stripe/Shopify/Booking.com 的交叉審查與平台級成熟度缺口分析見 reviews/2026-06-04-order-money-model.md。


1. 設計動機

orders 只當營運預訂外殼,財務真相拆成七層分類帳/聚合。這個拆法讓下列需求都能從第一原則表達,而不必把萬用的單一金額欄一直 mutate:部分退款、訂單層折讓、金流手續費、付款後房型升級補款、供應商應付、會計匯出、以及「原始下單 vs 當前金額」的可重建性。

核心原則:

  • 營運外殼 ≠ 財務真相:orders 只持有派生快取(payment_state/refund_state),金額一律由分類帳算。
  • 意圖/嘗試/現金三分離:「打算收」(intents)、「怎麼收」(attempts)、「實際現金」(transactions)各一層,不再用一列隨流程 mutate。
  • 退款是一等公民工作流,不是訂單/付款的狀態副作用。
  • append-only 財務真相:order_charge_lines / payment_transactions 只增不改,改錯靠 append 反向/負額列。
  • 營收認列 ≠ 收到現金:現階段訂單付款狀態報表用收到現金;正式營收認列留未來會計模組。例外:公司損益 / 每梯次毛利的營收認列用「出團月 × 成交應收(gross_receivable)」,非現金月——見 internal-external-ledger §3.1 / operating-expenses §3.1(避免訂金 / 尾款跨月誤計)。

2. 模型(七層分類帳/聚合)

圖表載入中…

每張表的職責 + 關鍵不變量(逐欄位看 code packages/core/src/schema/business.ts;這裡不複製欄位清單):

表職責(一句)關鍵不變量
orders營運預訂外殼,不擁有財務真相三軸狀態:booking_state / payment_state / refund_state,後兩者派生自分類帳、快取於此供過濾。無 total_twd/deposit_due_twd/balance_due_twd/paid_at/payment_provider/payment_method
order_lines買了什麼(product-neutral)tour/lodging/addon/fee;snapshot 為顯示/稽核用,不得變成第二份金額
tour_booking_lines / lodging_booking_lines產品專屬明細(梯次/房型快照)一 order_line 一筆
lodging_nightly_snapshots逐晚價格稽核(Booking.com 式)日曆價日後變動仍可重建原始逐晚定價
order_charge_lines應收分類帳(append-only)——客人為何欠錢signed 金額;正=增應收、負=減;改錯只能 append type='reversal';父 FK 一律 restrict(見 §4)。type 清單(DB CHECK order_charge_lines_type_valid 為權威):base_fare、lodging_night、room_upgrade、equipment_rental、equipment_deposit、lodging_onsite、transport_addon、discount(負)、tax、fee、manual_adjustment(正/負)、reversal(負,沖正)、failover_deposit_credit(負,見下)
payment_schedules收款計畫快照(訂金/尾款/全額/調整)current=open/satisfied、historical=cancelled/superseded;superseded 列用 replaces_schedule_id 保留歷史 intent trace;SUM(current schedule.target) == gross_receivable(核心不變量,見 §4 H3);current 計畫列 partial unique
payment_intents打算收的錢(非現金證明)amount = target − 已套用現金,floor 0;同 purpose 開放中 intent partial unique;一旦成功即終態,下一筆收款必建新 intent
payment_provider_configs租戶本地 provider 設定 + capability registryprovider 身分是資料不是 enum;secret 不入庫,只存 secret_ref
payment_attemptsgateway/手動付款嘗試(provider, provider_attempt_ref) unique(present 時);若無 concrete attempt ref,必須把 order ref 複寫到 provider_attempt_ref 或加 fallback partial unique;provider_order_ref 不全域唯一;callback 冪等;不 cascade-delete
payment_provider_eventswebhook 事件收件匣(provider, provider_event_id) unique;先存事件再冪等處理
payment_transactions現金分類帳(append-only)——報表/會計基準只存終態現金事實;idempotency_key unique;direction(in/out) + type(payment/refund/chargeback/reversal);fee_twd 顯式(不另開 type='fee' 列);父 FK 一律 restrict;outbound refund/chargeback 必填 refund_id(DB check)
refunds / refund_lines退款工作流refund_kind 與 affects_receivable 用 DB check 鎖死;退款現金列必填 refund_id
payables對外應付(供應商/嚮導/佣金)不是客戶退款
finance_export_events會計匯出 outbox與來源 money fact 同 tx 寫入(transactional outbox)。發射點四處,event_type ∈ payment_succeeded(inbound 收款 chokepoint recordCashTransaction direction='in')/ refund_paid(markRefundPaid)/ payable_paid(markPaymentRequestPaid)/ conversion_special(取消梯次殘餘結轉);冪等 on idempotency_key。消費者(drain)暫不實作(選項 A:台帳 xlsx 已滿足人工對帳),pending → exported|failed|skipped 狀態機介面預留。

金額慣例:整數 TWD(沿用全專案)。每欄留 currency default TWD 但 app 拒非 TWD;多幣別前所有聚合不分幣別(見 §8 Non-Goals)。本檔是營收 / 現金分類帳(orders / 收款 / 退款),鐵守 TWD——客人一律付台幣。成本側多幣別(地接 / 內外帳以外幣登記、匯率換算)是另一條軸,不進本檔,見 currency-exchange + internal-external-ledger §2.1.4。


3. 核心會計公式

用共享 helper(packages/core/src/orders/money.ts / money-state.ts),禁止 inline 重算。payment_transactions 只含終態現金事實;非終態(pending/failed/expired)留 payment_attempts / payment_provider_events。

gross_receivable      = SUM(order_charge_lines.amount_twd)                      -- signed
collected             = SUM(tx.amount_twd) WHERE direction='in'
customer_cash_returned= SUM(tx.amount_twd) WHERE direction='out' AND type IN (refund,chargeback,reversal)
customer_cash_applied = collected − customer_cash_returned
outstanding           = gross_receivable − customer_cash_applied
fee                   = SUM(tx.fee_twd)
net_cash              = SUM(in:+amount, out:−amount) − fee

退款兩義(關鍵區分):

  • 減應收退款(取消/降價/服務失敗,refund_kind='receivable_reduction', affects_receivable=true):先 append 負 order_charge_lines 減應收,再寫 outbound 退款現金列。
  • 溢收退款(overpayment_return, affects_receivable=false):不動應收,只退多收的現金。不產生退款 badge、不算 refund_state。
  • 減應收不得把應收打成負數:若退款/折讓超過目前應收,必須拆成「減應收退款」+「溢收退款」,或另走明確 credit memo/稽核流程(見 §4 H5)。
  • refund_state 分母刻意排除溢收現金:customer_facing_refund_base = LEAST(collected, gross_receivable + customer_facing_refunded)。範例:應收基準 10,000、實收 12,000、做 10,000 減應收退款 + 2,000 溢收退還 → refunded,不是 partially_refunded。

營收認列 ≠ 收到現金。現階段報表用收到現金即可,營收認列留未來會計模組。

3.1 攤提份額(訂單層金額 → 出團/行程粒度)

上述公式全是訂單粒度。訂單層金額要進出團/行程粒度報表時(跨出團訂單一張單沾多個出團、甚至多個行程),唯一 seam 是 packages/core/src/orders/allocation.ts(orderMoneyByDeparture() CTE builder + allocByShare() 攤提 helper)——禁止裸 LEFT JOIN order_charge_lines(tour-line join 會把整單金額灑到每一列)或自刻攤提 CTE。

攤提語意:

  • 釘線金額直接歸屬:order_charge_lines.order_line_id 有值的列直接歸該 line 的出團,不參與攤提;回沖列(order_line_id NULL + reverses_charge_line_id)繼承被回沖列的釘定。
  • 訂單層池按權重攤分:未釘線的訂單層金額按各 tour line 的票價快照 × 人數(unit_price_twd_snapshot × party_size)權重分。
  • 整數分毫不差:FLOOR 份額(FLOOR 非 trunc,負池/折扣也守恆)+餘數歸最高權重列。
  • 權重歸零 fallback:Σ 權重 = 0(免費單/快照 0)時退人數權重,再退行數。
  • 守恆是硬 invariant:每張訂單 Σ 攤提份額 = 訂單總額,整數不差一元;出團/行程粒度報表加總回訂單粒度必須相等。行程粒度=出團 roll up(出團屬於恰好一個行程),不另做第二個 builder。
  • 所有金額軸走同一份額:應收/折扣/毛利分成三軸由 orderMoneyByDeparture() 直接輸出;其他 order-grain 量測(如「當月入帳現金」「該單已收/退回現金」)由 caller 準備、經 allocByShare() 乘同一份額——入帳現金也按份額攤(#10 拍板:月報 byTrip 由「沾到就全計」改份額制,「總營收 = byTrip 加總」守恆自動成立)。
  • tour 業務範圍以 tour booking line 的存在為準(#20):報表把「總量」與「分量」對帳時,總量端(如月報頭條 KPI)圈範圍必須用「訂單有 tour booking line」,不用 order shell 的 kind——攤提歸屬只可能來自 line,kind 圈出來的總量會把無 line 的異常單現金算進總量、卻攤不進任何出團,守恆即破。kind 與 line 在 app 寫入下恆等(checkout/手動開單同 tx 插 line),此規則只在資料異常時分勝負:異常單的現金一律不計。

現行 caller:團控出團列表(listUpcomingDeparturesForControl)、每梯次財務摘要(getDepartureFinanceSummary,control-finance §7)、每梯次毛利(listDepartureLedgers,internal-external-ledger §3.1)、月報 byTrip(getMonthlyReport)、dashboard 熱門行程(getTopProducts,dashboard §7.3)。


4. 不變量與設計決議

以下是設計拍板的決議,實作不可違反。完整論述見 review doc「Cross-Review Resolutions」。

  • append-only 財務真相不可刪:order_charge_lines、payment_transactions 的所有父 FK 一律 restrict/no action——set null 是 UPDATE,會撞 append-only trigger;訂單一旦有財務列就禁止硬刪,取消=改狀態 + append 反向/負額列。
  • 不 cascade attempts:payment_intents 取消/過期不得連帶刪 payment_attempts(保留 late callback / replay / provider audit)。
  • payment_state 是「現金對當前應收的覆蓋」,退款顯示分開走 refund_state——溢收退款不可把已付單變 partially_refunded。
  • 手續費存在現金列的 fee_twd,不開 type='fee' 獨立列。退款預設 fee_twd=0(不退原始手續費)。
  • chargeback / reversal 是 outbound,要減 net cash 與 customer cash applied(同退款)。reversal 專指「沖正錯誤的 inbound」;要沖正錯誤的 outbound 退款 → 寫一筆新的 inbound type='payment'。
  • refund_kind ↔ affects_receivable 用 DB check 鎖死:receivable_reduction→true、overpayment_return→false,不准矛盾列。
  • provider 名是 text 目錄不是 DB enum;capability 檢查優先讀 payment_provider_configs,env 選定 adapter 的靜態 metadata 只作本地/dev fallback。

寫入收尾(money-write epilogue):所有改 charge/schedule/cash/refund 的寫入流程一律經 withOrderMoneyMutation(tx, orderIds, fn)(orders/money.ts)——業務寫入(含各自的 schedule 對齊策略)放 fn,出場統一 recomputeAndCacheOrderState → advanceBookingOnPayment → assertOrderFinancialInvariants。advanceBooking 一律跑(ADR 0001):訂單確認的條件是「錢夠了」,不分現金入帳或應收下降(部分收款後折讓補齊也會 confirm);它自帶狀態守衛(只推 pending_payment → confirmed)。seam 由靜態掃描測試(money-write-epilogue.test.ts)鎖死:import 寫入 primitive 必用 wrapper、money.ts 之外禁直呼收尾三件套。

四個由 DB 兜底、不能只靠 app 紀律的不變量:

  • H2 並發一致性:插入現金列 + 重算寫回 payment_state/refund_state 同一個 tx,且在動現金列前對該 order 列 SELECT … FOR UPDATE(所有 cash write path:webhook / return URL / cron / 手動入帳皆然)。
  • H3 計畫總和:SUM(current payment_schedules.target_amount_twd) == gross_receivable,在所有改 charge/schedule/cash/refund 的 write helper 同 tx assertion(assertOrderFinancialInvariants);另用全表掃描測試兜底(跨表,CHECK 表達不了)。current schedule = open/satisfied;historical = cancelled/superseded。已被 submitted/processing/succeeded/expired attempt 引用的 schedule 不原地改,改用 superseded + replaces_schedule_id。reconcile 是 diff-based(reconcileSchedulesToReceivable,orders/money.ts):應收增加時不動既有訂金/尾款分期結構(due_at/purpose/sequence 保留),只 append 一列 purpose='adjustment' 補差額(不自動鑄 intent);應收減少時兩段 shrink(先縮未收現金的 open 列、再縮已覆蓋列),shrink 保留原列結構、歸零列轉 superseded、被 attempt 引用者走替換列——不再整批 supersede 重建單一 full 列。
  • H4 單 intent 單收款:partial unique payment_transactions (payment_intent_id) WHERE direction='in' AND type='payment' + inbound payment 必掛 intent 的 DB check(防 null intent 繞過);intent succeeded 為終態、禁止再建 attempt。手動分次收款也一樣:每次成功收款必建新 intent + 新 attempt。真實雙重收款(surplus)有入帳路徑、H4 不放寬:客戶對同一 intent 真付第二筆(殘留舊收銀台分頁)時,webhook 不再拒寫——鑄一顆新 purpose='adjustment' 的 surplus intent(直接 succeeded、不掛 schedule),attempt 重掛到 surplus intent,現金列掛 surplus intent 入帳(H4 unique 不被違反),記 audit payment.surplus_received;訂單轉 overpaid,由溢收退還收斂。金額不符仍 amount_mismatch fail-closed。
  • H5 不可過退/不可負應收:退款標記已付時鎖 order 重算,斷言 customer_cash_applied >= 0 且 gross_receivable >= 0;減應收退款金額不得超過當前應收(超出走「溢收退還」或拆單,不默默寫負)。退款建立/送審雙守門(assertRefundFinancials,create 與 submit 各驗一次):退款額 + admin_fee + remit_fee ≤ customer_cash_applied,且 receivable_reduction 需 gross_receivable > 0;mark-paid 對同一 charge line 的重複 reversal 直接擋(CHARGE_LINE_ALREADY_REVERSED),逐列稽核鏈不失真。
  • 取消統一沖零:booking_state='cancelled' ⇒ gross_receivable=0 且無 open schedule(全表掃描測試可兜底的不變量)。共用 helper sweepOrderReceivableOnCancel(orders/cancel-sweep.ts):append 負 reversal 沖掉全額 → reconcileSchedulesToReceivable → 重算 + 斷言——cancelOrder、ATM 逾期 sweep、棄單 sweep 三路徑共用;lottery failover 與 lodging 取消 inline 同一語意(lodging 側待整併 import)。現金留原單(payment_state 派生 paid);取消單退款因 gross 已 0 一律走 overpayment_return。取消同 tx 歸還折扣碼兌換名額(見 discount-code)。
  • 退刷現金列 type 一律 refund:信用卡原路退刷(execution_method='chargeback')的 outbound 現金列也寫 type='refund'——通道由 execution_method 表達,type='chargeback' 保留給未來 dispute 生命週期,不污染語意。

5. 狀態機

booking_state : pending_payment → confirmed → completed
                pending_payment → cancelled ; confirmed → cancelled

payment_state : unpaid | partially_paid | deposit_paid | paid | overpaid   (派生 + 快取)
  if receivable=0 and collected>0 → paid     // 已結清;退款 badge 由 refund_state 帶
  elif receivable=0               → unpaid    // 免費/付款前取消
  elif cash_applied=0             → unpaid
  elif cash_applied>receivable    → overpaid
  elif cash_applied=receivable    → paid
  elif deposit_target>0 and cash_applied>=deposit_target → deposit_paid
  else → partially_paid

refund_state  : none | partially_refunded | refunded   (只算 affects_receivable=true 的已付退款)

intent_state  : requires_payment → processing → succeeded
                requires_payment → cancelled ; processing → expired ; processing → failed/requires_payment

refund_state(workflow): draft → submitted → approved → processing → paid
                        submitted → rejected ; approved → cancelled ; processing → failed

UI 鐵則:任何畫面(admin/customer/dashboard/leaderboard)badge 禁止單獨顯示 payment_state,一律走 deriveOrderDisplayStatus(booking_state, payment_state, refund_state)。理由:paid 只代表「應收被現金覆蓋」不代表錢還在手上——已全退單仍 payment_state=paid,退款顯示靠 refund_state 帶(與 Stripe 全退 PaymentIntent 仍 succeeded 一致)。


6. 關鍵流程(濃縮)

  • Tour 結帳:race-safe 預留席次 → 建 orders → order_lines(tour) → tour_booking_lines → order_charge_lines(base_fare)(折扣碼則加 order_charge_lines(discount, −N) + 保留 order_discounts 快照)→ 建 payment_schedules(全額一列 / 訂金+尾款兩列)→ 從當前可付列建首個 payment_intents。
  • Lodging 結帳:預留每晚庫存 → order_lines(lodging) → lodging_booking_lines → lodging_nightly_snapshots 逐晚 → 每晚一列 order_charge_lines(lodging_night) → schedules → intent。
  • Hosted redirect(ECPay/Stripe Checkout/Line Pay…):建/重用未成功 intent → 建 attempt → adapter 產 redirect → callback 先寫 payment_provider_events(有收件匣時)→ 鎖 order 列 → 以 (provider, provider_attempt_ref) 上鎖(無 concrete attempt ref 時必須有 fallback partial unique 才能用 (provider, provider_order_ref))→ 驗簽 + 金額 → attempt succeeded → 插 payment_transactions(in, payment) → 重算 state + 斷言不變量。
  • 虛擬帳號/延遲付款(ECPay ATM):建 intent/attempt → 取號 callback 回填 virtual_account/pay_expire_at(此時尚非現金)→ 入帳 callback 才插現金列 → 逾期 sweep 把 attempt/intent 標 expired(無成功現金時)。
  • 退款:建/核 refunds →(減應收則先 append 負 charge line + reconcile schedule)→ gateway 退或銀行/手動退 → 成功插 payment_transactions(out, refund) 帶 refund_id → 重算 state。信用卡原路退刷(execution_method='chargeback'):executeRefund 於 markRefundPaid 的 DB tx 之外先真打 ECPay DoAction R(憑證經 resolveEcpayCredentials per-tenant;test provider 走 mock),成功把 ECPay TradeNo 當 provider_refund_no 寫上退款現金列;DoAction 失敗(或找不到原信用卡交易)自動降級手動匯款並記 audit(refund.gateway_refund_*),不讓退款卡死。外部副作用冪等(claim-then-fire):DB 去重只保護寫入、不保護 DoAction——打之前先鎖內佔 refunds.gateway_attempted_at,成功後 gateway_trade_no 落地(重試重用、永不重打;ECPay 部分退刷可累計,重打=客戶多退一次);明確失敗清回 claim 允許重試;attempted 而無 trade_no(in-flight/程序中斷結果不明)→ fail-closed 拋錯,由會計向 gateway 後台確認後再處理。mark-paid 的 action/API 皆帶 expectedOrderId 做 child↔parent 綁定(anti-IDOR)。ATM/現金一律維持手動。sandbox 特店退刷可測性未驗證,live 驗證推遲到 prod 憑證後(staging 以 PAYMENT_PROVIDER=ecpay 執行 chargeback 時會真打 sandbox DoAction)。
  • 付款後房型升級:append order_charge_lines(room_upgrade) → 變回 outstanding → 建 payment_schedules(adjustment) + payment_intents(adjustment) → UI 出補款連結(不靜默把已付單變大的已付單)。
  • 手動入帳:每次收款建新 intent + 新 payment_attempt(manual);不得重用已 succeeded intent → 插現金列(idempotency manual:{attempt_id}:payment)。
  • ECPay legacy 對映:merchant_trade_no→provider_order_ref(+provider_attempt_ref)、provider_trade_no→provider_trade_no、bank_code/v_account/pay_expire_at→attempt、raw_callback→payment_provider_events、status='paid'→tx(in,payment)。ECPay 是 adapter,不是 schema 設計中心。
  • 跨訂單訂金結轉(抽籤 failover,多跳沿用):未中籤轉備案時,旅客已付訂金以應收 credit 法帶到目標單,不在 payment_transactions 建任何 transfer 列——保持「現金分類帳 = 純銀行真相」。具體做法:目標單 append order_charge_lines(type='failover_deposit_credit', amount=−cap)(以 failover_from_order_id 串回上一跳稽核)→ 目標 gross_receivable = 成交價 − cap = 尾款,建單一 balance schedule;上一跳則逐筆 reversal 沖 gross_receivable=0,現金留原單(payment_state 派生 paid,booking_state=cancelled)。鏈式 cascade(多備案)即多次套用同一 credit 法、無新增列型別——訂金現金實體只留在鏈首,下游每跳訂金以 credit 形式存在;故 cap 用 carriedDeposit = customerCashApplied(上一跳) + |上一跳已結轉的 failover_deposit_credit|(cap = min(carriedDeposit, 成交價)),否則第二跳起訂金蒸發。清單走完(exhaust)退款回鏈首:對持現金的鏈首建 refunds,種類依鏈首 money state——gross 未沖→receivable_reduction、gross 已沖 0→overpayment_return(純退現金不動應收,避免 H5 OVER_REFUND)。order_backup_choices(有序備案清單)是排程資料、不在金流層。見 backup-itinerary-failover §3.2/§3.3。

7. 與其他 spec 的關係

金流模型收斂了多個財務功能的共用底座。對照表(避免雙重建模):

概念金流歸屬關聯 spec
訂單應收SUM(order_charge_lines);訂單外殼不保存金額真相orders
訂單狀態三軸 booking_state/payment_state/refund_stateorders
付款 intent/attempt/cashpayment_intents/payment_attempts/payment_transactions(ECPay 為 adapter)—
掛帳折讓order_charge_lines(type='manual_adjustment')(append-only + reversal)control-finance;order_discounts 折扣快照另存
訂金/尾款payment_schedules(deposit/balance/full 列)trip-pricing
客戶退款refunds + refund_lines 工作流本檔
供應商/嚮導/佣金請款payables / payable_itemscontrol-finance
成本登記簿departure_costs 不變,接 payablescontrol-finance
出團/行程粒度攤提orders/allocation 唯一 seam(§3.1;守恆硬 invariant)internal-external-ledger §3.1、dashboard §7.3

control-finance 擁有「成本登記 → 請款核簽」兩階段;請款端資料層以 payables 為準。掛帳折讓的 append-only + reversal 慣例直接沿用到 order_charge_lines。


8. Non-Goals 與開放議題

現階段不做(設計邊界,非缺陷):

  • 授權/請款兩段(auth-then-capture):attempt 無 authorized 狀態,payment_transactions 只收終態現金。serverSideCapture 是保留 capability,現行 provider 一律回報 false。
  • Dispute 生命週期:chargeback 只是單筆 outbound 終態;無 needs_response→won/lost 案件流程、無 dispute fee。dispute 勝訴回補未來用 type='dispute_reversal' 或完整 workflow,不用 inbound reversal。
  • Settlement / payout / 對帳批次:有 fee_twd/net_twd,無 payout 批次、bank reconciliation。
  • 多幣別(營收側):本檔營收 / 現金分類帳每欄留 currency default TWD,但 app 拒非 TWD;所有聚合不分幣別(營收多幣別上線時每條聚合與 helper 簽名都要加 currency 維度)。成本側多幣別已落地於 currency-exchange + internal-external-ledger §2.1.4(不影響本檔)。
  • return + restock + refund line item 全流程(Shopify 式庫存回補)。

對標 Stripe / Shopify / Booking.com 的完整交叉審查與平台級成熟度缺口分析見 reviews/2026-06-04-order-money-model.md。

開放議題:

  1. order_charge_lines 是否為唯一 itemized 定價來源,或 order_lines 也存顯示用總額供 UI 快讀?
  2. 首個會計整合對象?需要哪種科目粒度?
  3. 下一家 provider 的 must-have capability:redirect-only / 虛擬帳號 / partial refund / fee reporting / settlement import?