線稿索引所有 spec最後更新 2026-07-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 清單:base_farediscount(負)、manual_adjustment(正/負)、reversal(負,沖正)、room_upgradelodging_nightfailover_deposit_credit(負,見下)
payment_schedules收款計畫快照(訂金/尾款/全額/調整)current=open/satisfied、historical=cancelled/supersededsuperseded 列用 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_kindaffects_receivable 用 DB check 鎖死;退款現金列必填 refund_id
payables對外應付(供應商/嚮導/佣金)不是客戶退款
finance_export_events會計匯出 outbox與來源 money fact 同 tx 寫入(transactional outbox)。發射點四處,event_typepayment_succeeded(inbound 收款 chokepoint recordCashTransaction direction='in')/ refund_paidmarkRefundPaid)/ payable_paidmarkPaymentRequestPaid)/ 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

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


4. 不變量與設計決議

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

  • append-only 財務真相不可刪order_charge_linespayment_transactions所有父 FK 一律 restrict/no action——set null 是 UPDATE,會撞 append-only trigger;訂單一旦有財務列就禁止硬刪,取消=改狀態 + append 反向/負額列。
  • 不 cascade attemptspayment_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_kindaffects_receivable 用 DB check 鎖死receivable_reduction→trueoverpayment_return→false,不准矛盾列。
  • provider 名是 text 目錄不是 DB enum;capability 檢查優先讀 payment_provider_configs,env 選定 adapter 的靜態 metadata 只作本地/dev fallback。

四個由 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
  • 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。
  • H5 不可過退/不可負應收:退款標記已付時鎖 order 重算,斷言 customer_cash_applied >= 0gross_receivable >= 0;減應收退款金額不得超過當前應收(超出走「溢收退還」或拆單,不默默寫負)。

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 預留席次 → 建 ordersorder_lines(tour)tour_booking_linesorder_charge_lines(base_fare)(折扣碼則加 order_charge_lines(discount, −N) + 保留 order_discounts 快照)→ 建 payment_schedules(全額一列 / 訂金+尾款兩列)→ 從當前可付列建首個 payment_intents
  • Lodging 結帳:預留每晚庫存 → order_lines(lodging)lodging_booking_lineslodging_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'):executeRefundmarkRefundPaid 的 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_nobank_code/v_account/pay_expire_at→attemptraw_callback→payment_provider_eventsstatus='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 派生 paidbooking_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-financeorder_discounts 折扣快照另存
訂金/尾款payment_schedules(deposit/balance/full 列)trip-pricing
客戶退款refunds + refund_lines 工作流本檔
供應商/嚮導/佣金請款payables / payable_itemscontrol-finance
成本登記簿departure_costs 不變,接 payablescontrol-finance

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?

Changelog

版本日期變更Commit
72026-07-04G2 金流深水:(G2-1) finance_export_events 補齊四發射點(payment_succeeded / refund_paid / payable_paid / conversion_special,皆與來源同 tx,冪等 on idempotency_key),drain 留介面(選項 A);(G2-2) 信用卡 execution_method='chargeback' 退款經 executeRefund 真打 ECPay DoAction R 原路退刷(per-tenant 憑證、test provider mock),失敗自動降級手動並記 refund.gateway_refund_* audit,sandbox 實測 pending;經 codex 對抗覆核補 claim-then-fire 冪等refunds.gateway_attempted_at/gateway_trade_no,防並發/重試重複退刷)與 mark-paid child↔parent 綁定。
62026-06-30多備案鏈式 cascade:§6「跨訂單訂金結轉」補多跳沿用——cap 改用 carriedDeposit(現金 + 結轉 credit) 防多跳訂金蒸發;exhaust 退款回鏈首依 money state 選 receivable_reduction/overpayment_return;註明 order_backup_choices 不在金流層、無新增列型別。
52026-06-30落地後更新(13-task SDD):§2 order_charge_lines 表補 type 清單(含 failover_deposit_credit);§6 加「跨訂單訂金結轉(抽籤 failover)」流程——明載應收 credit 法、credit cap 公式、不建 payment_transactions transfer 列的設計決議。
42026-06-28金流→會計流程一致性修正 #2(§1):限縮「報表用收到現金」範圍——僅指訂單付款狀態報表;公司損益 / 每梯次毛利的營收認列改用「出團月 × 成交應收」(見 internal-external-ledger §3.1 / operating-expenses §3.1),避免訂金 / 尾款跨月誤計。
32026-06-05移除版本代號措辭;同步 payables 整併,出款核簽歸 control-finance,review 快照改連無版本代號檔名。
22026-06-05金流模型落地併入 main 後清理:移除舊模型問題剖析、對標 Stripe/Shopify 差距分析表、平台級成熟度缺口、migration 落地計畫、已完成的缺口測試清單等一次性 scaffolding;改寫為現在式設計 SSOT。6882a48
12026-06-04初版:金流模型 SSOT(七層分類帳 + 核心公式 + 不變量/決議 + 狀態機/流程 + 對標 Stripe/Shopify 交叉審查 + 成熟度缺口 + 取代關係)。