单一数据源 (Single Source of Truth) 设计 — 解决支付页面多处金额不一致的 Penny Rounding 问题
Checkout 页面有四处显示"总金额",来自三个不同组件:
| 位置 | 组件 | 数据来源 |
|---|---|---|
| Total (Order Details) | OrderDetailsSection |
组件内独立计算 |
| Amount Due (Order Details) | OrderDetailsSection |
state.amountDue (prop 传入) |
| Payment Amount (Split) | SplitPaymentSelector |
state.amountDue (prop 传入) |
| Amount Due (Confirm) | NewCheckoutFlow (inline) |
effectivePaymentAmount ← state.amountDue |
(subtotalBeforeTax - discount) × (1 + TAX_RATE) 计算,
与 useCheckoutState 的 subtotal - discount × (1 + TAX_RATE) 代数等价但中间四舍五入不同,导致差 1 分钱。
TAX_RATE = 0.08875 (8.875%)
系统中价格存储规则:
item.subtotal 和 state.subtotal 是含税金额 (API 价格 × 1.08875 后四舍五入)discount.amount 是税前折扣$146.99 / 1.08875 = $135.008... → $135.01 ← 错误!应该逐项反推再求和 = $135.00
flowchart TD
subgraph API["后端 API"]
DB["sub_appointments\nprice = 税前价格"]
end
subgraph LOAD["loadInvoice()"]
PARSE["parseFloat(sub.price)"]
ITAX["逐项加税\nroundToTwo(price × 1.08875)"]
ITEM["item.subtotal = 含税单价"]
end
subgraph HOOK["useCheckoutState"]
SUM["state.subtotal\n= Σ item.subtotal\n(含税总额)"]
DISC["state.totalDiscountAmount\n= Σ discount.amount\n(税前折扣)"]
DUE["state.amountDue\n= subtotal − discount×(1+TAX) − paid"]
end
subgraph ODS["OrderDetailsSection"]
BT["subtotalBeforeTax\n= Σ roundToTwo(item / 1.08875)\n逐项反推"]
TAD["totalAfterDiscount\n= subtotal − discount×(1+TAX)\n同 useCheckoutState 公式"]
ADPT["afterDiscountPreTax\n= subtotalBeforeTax − discount"]
TAX["taxAmount\n= totalAfterDiscount − afterDiscountPreTax\n反推保证加法恒等"]
PAID["totalPaid = Σ transactions"]
AMD["actualAmountDue\n= totalAfterDiscount − totalPaid"]
end
subgraph DISPLAY["页面显示"]
D1["Subtotal: subtotalBeforeTax"]
D2["Discount: −discount.amount"]
D3["After Discount: afterDiscountPreTax"]
D4["Tax: taxAmount"]
D5["Total: totalAfterDiscount"]
D6["Amount Due: actualAmountDue"]
D7["Payment Amount: state.amountDue"]
D8["Confirm Amount: effectivePaymentAmount"]
end
DB --> PARSE --> ITAX --> ITEM
ITEM --> SUM
SUM --> DUE
DISC --> DUE
ITEM -->|"items prop"| BT
SUM -->|"subtotal prop"| TAD
TAD --> ADPT --> TAX
TAD --> AMD
BT --> D1
DISC -->|"discounts prop"| D2
ADPT --> D3
TAX --> D4
TAD --> D5
AMD --> D6
DUE --> D7
DUE -->|"splitAmount ?? amountDue"| D8
style DUE fill:#1a365d,stroke:#3b82f6,stroke-width:2px
style TAD fill:#1a365d,stroke:#3b82f6,stroke-width:2px
style D5 fill:#064e3b,stroke:#22c55e
style D6 fill:#064e3b,stroke:#22c55e
style D7 fill:#064e3b,stroke:#22c55e
style D8 fill:#064e3b,stroke:#22c55e
所有金额从以下 6 步依次派生,不允许独立重算:
TAX_RATE = 0.08875
roundToTwo = (v) => Math.round(v * 100) / 100
// ① 税前小计:逐项反推再求和(不是总额反除!)
subtotalBeforeTax = roundToTwo(
Σ roundToTwo(item.subtotal / (1 + TAX_RATE))
)
// ② Total(含税、折后):权威公式,与 useCheckoutState 完全一致
totalAfterDiscount = roundToTwo(
subtotal − totalDiscountAmount × (1 + TAX_RATE)
)
// ③ 折后税前
afterDiscountPreTax = roundToTwo(subtotalBeforeTax − totalDiscountAmount)
// ④ 税额:反推(确保 afterDiscountPreTax + tax = total 恒等)
taxAmount = roundToTwo(totalAfterDiscount − afterDiscountPreTax)
// ⑤ 已支付
totalPaid = Σ (tx.total_amount − tx.tip_amount − tx.refunded_amount)
// ⑥ 应付
actualAmountDue = roundToTwo(max(0, totalAfterDiscount − totalPaid))
| 方法 | 公式 | 结果 (示例) | 问题 |
|---|---|---|---|
| 独立计算 ✗ | afterDiscountPreTax × TAX_RATE |
$120 × 0.08875 = $10.65 | $120 + $10.65 = $130.65 ≠ Total $130.66 |
| 反推 ✓ | totalAfterDiscount − afterDiscountPreTax |
$130.66 − $120.00 = $10.66 | $120 + $10.66 = $130.66 = Total ✓ |
| 步骤 | 计算 | 结果 |
|---|---|---|
| API 返回税前价格 | Classic French Manicure / Pedicure | $100.00 / $35.00 |
| 逐项加税 | roundToTwo(100 × 1.08875) / roundToTwo(35 × 1.08875) | $108.88 / $38.11 |
| ① subtotal (含税) | 108.88 + 38.11 | $146.99 |
| ① subtotalBeforeTax | roundToTwo(108.88/1.08875) + roundToTwo(38.11/1.08875) = 100.00 + 35.00 | $135.00 |
| 折扣 (税前) | 节日特惠 | $15.00 |
| ② totalAfterDiscount | roundToTwo(146.99 − 15 × 1.08875) = roundToTwo(146.99 − 16.33) | $130.66 |
| ③ afterDiscountPreTax | roundToTwo(135.00 − 15.00) | $120.00 |
| ④ taxAmount | roundToTwo(130.66 − 120.00) | $10.66 |
| 验证恒等式 | 120.00 + 10.66 | $130.66 ✓ |
| 步骤 | 计算 | 结果 |
|---|---|---|
| ② totalAfterDiscount | roundToTwo(146.99 − 0) = 146.99 | $146.99 |
| ③ afterDiscountPreTax | 135.00 − 0 | $135.00 |
| ④ taxAmount | 146.99 − 135.00 | $11.99 |
| 验证恒等式 | 135.00 + 11.99 | $146.99 ✓ |
注意:$11.99 ≠ $135 × 8.875% = $11.98,这 1 分钱差异来自逐项计税的四舍五入,是正确的行为。
flowchart LR
subgraph NCF["NewCheckoutFlow (父组件)"]
STATE["useCheckoutState()"]
EPA["effectivePaymentAmount\n= splitAmount ?? state.amountDue"]
end
subgraph ODS["OrderDetailsSection"]
direction TB
O1["Subtotal"]
O2["Discount"]
O3["After Discount"]
O4["Tax"]
O5["Total"]
O6["Amount Due"]
end
subgraph SPS["SplitPaymentSelector"]
S1["Payment Amount"]
end
subgraph CONFIRM["Confirm Payment"]
C1["Amount Due"]
end
STATE -->|"items, subtotal,\ndiscounts, amountDue"| ODS
STATE -->|"amountDue, subtotal,\nitems, discounts"| SPS
STATE --> EPA --> CONFIRM
style STATE fill:#1a365d,stroke:#3b82f6,stroke-width:2px
style O5 fill:#064e3b,stroke:#22c55e
style O6 fill:#064e3b,stroke:#22c55e
style S1 fill:#064e3b,stroke:#22c55e
style C1 fill:#064e3b,stroke:#22c55e
| 显示位置 | 变量 | 类型 | 计算方式 |
|---|---|---|---|
| Subtotal | subtotalBeforeTax |
派生 | Σ roundToTwo(item.subtotal / (1+TAX)) |
| Discount | discount.amount |
源数据 | 后端返回的税前折扣 |
| After Discount | afterDiscountPreTax |
派生 | subtotalBeforeTax − totalDiscountAmount |
| Tax | taxAmount |
反推 | totalAfterDiscount − afterDiscountPreTax |
| Total | totalAfterDiscount |
权威 | subtotal − discount × (1+TAX) 同 useCheckoutState |
| Amount Due | actualAmountDue |
派生 | totalAfterDiscount − totalPaid |
| Payment Amount | state.amountDue |
权威 | useCheckoutState 内 effect 计算 |
| Confirm Amount | effectivePaymentAmount |
派生 | splitAmount ?? state.amountDue |
| 文件 | 职责 | 关键变量 |
|---|---|---|
hooks/useCheckoutState.ts |
状态管理 Hook — 价格计算的权威源 | state.subtotal, state.amountDue, state.totalDiscountAmount |
Payment/NewCheckoutFlow.tsx |
页面编排器 — 加载数据、向子组件传递 props | effectivePaymentAmount, splitAmount |
Payment/OrderDetailsSection.tsx |
订单明细展示 — 6 步派生计算链 | subtotalBeforeTax, totalAfterDiscount, taxAmount, actualAmountDue |
Payment/SplitPaymentSelector.tsx |
分单模式选择 — 直接引用 amountDue prop | amountDue (prop) |
Payment/PersonSplitView.tsx |
按人分单 — 逐人计算折后金额 | discountRate, guestServiceMap |
Payment/ServiceSplitView.tsx |
按服务分单 | discountRate |
totalAfterDiscount = subtotal − discount × (1 + TAX_RATE) — 全页面只用这一个公式算 TotalΣ roundToTwo(item / (1+TAX)) — 不要用总额反除tax = Total − afterDiscountPreTax — 保证加法恒等式subtotal / (1 + TAX_RATE) → 逐项四舍五入累积误差(preTax − discount) × (1 + TAX_RATE) 与权威公式中间步骤不同,产生 1 分钱差异preTax × TAX_RATE → 无法保证 Subtotal + Tax = Total× 1.08875更新于 2026-02-21 · Celoria Checkout Price Calculation Reference