Checkout 价格计算链路

单一数据源 (Single Source of Truth) 设计 — 解决支付页面多处金额不一致的 Penny Rounding 问题

1. 问题背景

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) effectivePaymentAmountstate.amountDue
Bug:OrderDetailsSection 的 "Total" 独立用 (subtotalBeforeTax - discount) × (1 + TAX_RATE) 计算, 与 useCheckoutStatesubtotal - discount × (1 + TAX_RATE) 代数等价但中间四舍五入不同,导致差 1 分钱。

2. 税率与基础概念

纽约州税率

TAX_RATE = 0.08875 (8.875%)

系统中价格存储规则:

逐项加税 vs 总额加税 (Penny Rounding 问题)

逐项加税(系统实际行为)

$100 × 1.08875 = $108.875 → $108.88
$35 × 1.08875 = $38.10625 → $38.11
合计 = $146.99

总额加税(理论值)

($100 + $35) × 1.08875
= $135 × 1.08875
= $146.98125 → $146.98

差异

$0.01
逐项四舍五入的累积误差。美国零售通常按逐项计税,所以 $146.99 是正确的。
关键规则:不能用含税总额反除来还原税前小计!
$146.99 / 1.08875 = $135.008... → $135.01 ← 错误!应该逐项反推再求和 = $135.00

3. 数据流全景

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

4. 核心公式(权威计算链)

所有金额从以下 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))

为什么 Tax 必须反推而不能直接算?

方法公式结果 (示例)问题
独立计算 ✗ 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 ✓
恒等式保证: afterDiscountPreTax + taxAmount ≡ totalAfterDiscount (永远成立)

5. 完整数值示例

场景:两项服务 + $15 折扣

步骤计算结果
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 分钱差异来自逐项计税的四舍五入,是正确的行为。

6. 组件引用关系

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

7. 涉及文件清单

文件职责关键变量
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

8. 设计原则总结

✓ 正确做法
  1. 单一权威公式totalAfterDiscount = subtotal − discount × (1 + TAX_RATE) — 全页面只用这一个公式算 Total
  2. 逐项反推税前Σ roundToTwo(item / (1+TAX)) — 不要用总额反除
  3. 税额反推tax = Total − afterDiscountPreTax — 保证加法恒等式
  4. 子组件只引用 props:不自己重算 Total/AmountDue
✗ 禁止做法
  1. 总额反除还原税前subtotal / (1 + TAX_RATE) → 逐项四舍五入累积误差
  2. 独立重算 Total(preTax − discount) × (1 + TAX_RATE) 与权威公式中间步骤不同,产生 1 分钱差异
  3. 独立计算 TaxpreTax × TAX_RATE → 无法保证 Subtotal + Tax = Total
  4. 硬编码税率计算:在多个组件中各写一遍 × 1.08875

更新于 2026-02-21 · Celoria Checkout Price Calculation Reference