一句話:訂單的「營運外殼」與「財務真相」徹底拆開——
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。
order_discounts 快照保留)· 行程定價(訂金/尾款 → payment_schedules)orders 只當營運預訂外殼,財務真相拆成七層分類帳/聚合。這個拆法讓下列需求都能從第一原則表達,而不必把萬用的單一金額欄一直 mutate:部分退款、訂單層折讓、金流手續費、付款後房型升級補款、供應商應付、會計匯出、以及「原始下單 vs 當前金額」的可重建性。
核心原則:
orders 只持有派生快取(payment_state/refund_state),金額一律由分類帳算。order_charge_lines / payment_transactions 只增不改,改錯靠 append 反向/負額列。gross_receivable)」,非現金月——見 internal-external-ledger §3.1 / operating-expenses §3.1(避免訂金 / 尾款跨月誤計)。每張表的職責 + 關鍵不變量(逐欄位看 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_fare、discount(負)、manual_adjustment(正/負)、reversal(負,沖正)、room_upgrade、lodging_night、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 registry | provider 身分是資料不是 enum;secret 不入庫,只存 secret_ref |
payment_attempts | gateway/手動付款嘗試 | (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_events | webhook 事件收件匣 | (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(沿用全專案)。每欄留
currencydefaultTWD但 app 拒非 TWD;多幣別前所有聚合不分幣別(見 §8 Non-Goals)。本檔是營收 / 現金分類帳(orders / 收款 / 退款),鐵守 TWD——客人一律付台幣。成本側多幣別(地接 / 內外帳以外幣登記、匯率換算)是另一條軸,不進本檔,見 currency-exchange + internal-external-ledger §2.1.4。
用共享 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。refund_state 分母刻意排除溢收現金:customer_facing_refund_base = LEAST(collected, gross_receivable + customer_facing_refunded)。範例:應收基準 10,000、實收 12,000、做 10,000 減應收退款 + 2,000 溢收退還 → refunded,不是 partially_refunded。營收認列 ≠ 收到現金。現階段報表用收到現金即可,營收認列留未來會計模組。
以下是設計拍板的決議,實作不可違反。完整論述見 review doc「Cross-Review Resolutions」。
order_charge_lines、payment_transactions 的所有父 FK 一律 restrict/no action——set null 是 UPDATE,會撞 append-only trigger;訂單一旦有財務列就禁止硬刪,取消=改狀態 + append 反向/負額列。payment_intents 取消/過期不得連帶刪 payment_attempts(保留 late callback / replay / provider audit)。payment_state 是「現金對當前應收的覆蓋」,退款顯示分開走 refund_state——溢收退款不可把已付單變 partially_refunded。fee_twd,不開 type='fee' 獨立列。退款預設 fee_twd=0(不退原始手續費)。reversal 專指「沖正錯誤的 inbound」;要沖正錯誤的 outbound 退款 → 寫一筆新的 inbound type='payment'。refund_kind ↔ affects_receivable 用 DB check 鎖死:receivable_reduction→true、overpayment_return→false,不准矛盾列。payment_provider_configs,env 選定 adapter 的靜態 metadata 只作本地/dev fallback。四個由 DB 兜底、不能只靠 app 紀律的不變量:
payment_state/refund_state 同一個 tx,且在動現金列前對該 order 列 SELECT … FOR UPDATE(所有 cash write path:webhook / return URL / cron / 手動入帳皆然)。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。payment_transactions (payment_intent_id) WHERE direction='in' AND type='payment' + inbound payment 必掛 intent 的 DB check(防 null intent 繞過);intent succeeded 為終態、禁止再建 attempt。手動分次收款也一樣:每次成功收款必建新 intent + 新 attempt。customer_cash_applied >= 0 且 gross_receivable >= 0;減應收退款金額不得超過當前應收(超出走「溢收退還」或拆單,不默默寫負)。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一致)。
orders → order_lines(tour) → tour_booking_lines → order_charge_lines(base_fare)(折扣碼則加 order_charge_lines(discount, −N) + 保留 order_discounts 快照)→ 建 payment_schedules(全額一列 / 訂金+尾款兩列)→ 從當前可付列建首個 payment_intents。order_lines(lodging) → lodging_booking_lines → lodging_nightly_snapshots 逐晚 → 每晚一列 order_charge_lines(lodging_night) → schedules → intent。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 + 斷言不變量。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)。order_charge_lines(room_upgrade) → 變回 outstanding → 建 payment_schedules(adjustment) + payment_intents(adjustment) → UI 出補款連結(不靜默把已付單變大的已付單)。payment_attempt(manual);不得重用已 succeeded intent → 插現金列(idempotency manual:{attempt_id}:payment)。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 設計中心。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。金流模型收斂了多個財務功能的共用底座。對照表(避免雙重建模):
| 概念 | 金流歸屬 | 關聯 spec |
|---|---|---|
| 訂單應收 | SUM(order_charge_lines);訂單外殼不保存金額真相 | orders |
| 訂單狀態 | 三軸 booking_state/payment_state/refund_state | orders |
| 付款 intent/attempt/cash | payment_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_items | control-finance |
| 成本登記簿 | departure_costs 不變,接 payables | control-finance |
control-finance 擁有「成本登記 → 請款核簽」兩階段;請款端資料層以
payables為準。掛帳折讓的 append-only + reversal 慣例直接沿用到order_charge_lines。
現階段不做(設計邊界,非缺陷):
authorized 狀態,payment_transactions 只收終態現金。serverSideCapture 是保留 capability,現行 provider 一律回報 false。type='dispute_reversal' 或完整 workflow,不用 inbound reversal。fee_twd/net_twd,無 payout 批次、bank reconciliation。currency default TWD,但 app 拒非 TWD;所有聚合不分幣別(營收多幣別上線時每條聚合與 helper 簽名都要加 currency 維度)。成本側多幣別已落地於 currency-exchange + internal-external-ledger §2.1.4(不影響本檔)。對標 Stripe / Shopify / Booking.com 的完整交叉審查與平台級成熟度缺口分析見 reviews/2026-06-04-order-money-model.md。
開放議題:
order_charge_lines 是否為唯一 itemized 定價來源,或 order_lines 也存顯示用總額供 UI 快讀?| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 7 | 2026-07-04 | G2 金流深水:(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 綁定。 | — |
| 6 | 2026-06-30 | 多備案鏈式 cascade:§6「跨訂單訂金結轉」補多跳沿用——cap 改用 carriedDeposit(現金 + 結轉 credit) 防多跳訂金蒸發;exhaust 退款回鏈首依 money state 選 receivable_reduction/overpayment_return;註明 order_backup_choices 不在金流層、無新增列型別。 | — |
| 5 | 2026-06-30 | 落地後更新(13-task SDD):§2 order_charge_lines 表補 type 清單(含 failover_deposit_credit);§6 加「跨訂單訂金結轉(抽籤 failover)」流程——明載應收 credit 法、credit cap 公式、不建 payment_transactions transfer 列的設計決議。 | — |
| 4 | 2026-06-28 | 金流→會計流程一致性修正 #2(§1):限縮「報表用收到現金」範圍——僅指訂單付款狀態報表;公司損益 / 每梯次毛利的營收認列改用「出團月 × 成交應收」(見 internal-external-ledger §3.1 / operating-expenses §3.1),避免訂金 / 尾款跨月誤計。 | — |
| 3 | 2026-06-05 | 移除版本代號措辭;同步 payables 整併,出款核簽歸 control-finance,review 快照改連無版本代號檔名。 | — |
| 2 | 2026-06-05 | 金流模型落地併入 main 後清理:移除舊模型問題剖析、對標 Stripe/Shopify 差距分析表、平台級成熟度缺口、migration 落地計畫、已完成的缺口測試清單等一次性 scaffolding;改寫為現在式設計 SSOT。 | 6882a48 |
| 1 | 2026-06-04 | 初版:金流模型 SSOT(七層分類帳 + 核心公式 + 不變量/決議 + 狀態機/流程 + 對標 Stripe/Shopify 交叉審查 + 成熟度缺口 + 取代關係)。 | — |