Web 支付模块 — CUJ 关键用户旅程

Checkout, Payments & Invoices | 更新时间: 2026-02-09

TOUCHES (Pages): checkout/page.tsx (redirect), checkout/[appointmentId]/page.tsx, invoices/[id]/page.tsx

TOUCHES (Components - Payment/): NewCheckoutFlow.tsx (主编排器), OrderDetailsSection.tsx, DiscountSection.tsx, GiftCardInput.tsx, TipSelector.tsx, PaymentMethodSelector.tsx, CashPaymentForm.tsx, TerminalSelector.tsx, PaymentInitiateModal.tsx, PaymentProgressModal.tsx, PaymentSuccessSummary.tsx, PaymentStatusBar.tsx, AmountDueDisplay.tsx, FinalDueAmount.tsx, PaidTransactionsList.tsx, SplitPaymentSelector.tsx, SplitPaymentModal.tsx, ServiceSplitView.tsx, PersonSplitView.tsx, ManualSplitInput.tsx, RefundModal.tsx, AddTerminalModal.tsx, CheckoutSkeleton.tsx, CheckoutErrorBoundary.tsx, PaymentCheckoutFlow.tsx (legacy)
权限要求: 结账页面需要 view_payments + process_payments 权限 (ProtectedRoute)

目录

CUJ 总览与优先级矩阵

CUJ优先级描述触发点业务价值E2E 状态
F1 P0 结账收款 — 现金 预约 pending_payment 后前台结账 核心营收路径 部分覆盖
F2 P0 结账收款 — 刷卡 客户选择信用卡/借记卡 核心营收路径 部分覆盖
F3 P1 折扣与礼品卡抵扣 结账时应用优惠 营销转化、客户留存 部分覆盖
F4 P1 拆分支付 客户要求多种支付方式 支付灵活性 部分覆盖
F5 P1 团体结账 多人预约结账 大额订单处理 缺失
F6 P1 退款 客户投诉/服务问题 客户满意度,纠纷处理 缺失
F7 P2 发票管理 查看/打印发票 财务记录 部分覆盖
F8 P2 终端管理 添加/管理 POS 终端 硬件设施管理 缺失

结账架构概览

核心组件关系

flowchart TD
    PAGE["checkout/[appointmentId]/page.tsx"] --> NCF["NewCheckoutFlow.tsx - 主编排器"]
    NCF --> ODS["OrderDetailsSection - 订单明细"]
    NCF --> DS["DiscountSection - 折扣/优惠券/礼品卡"]
    NCF --> TS["TipSelector - 小费"]
    NCF --> PMS["PaymentMethodSelector - 支付方式"]
    NCF --> SPS["SplitPaymentSelector - 拆分模式"]

    PMS -->|"Cash"| CPF["CashPaymentForm - 现金支付"]
    PMS -->|"Card"| TERM["TerminalSelector - 选择终端"]
    TERM --> PIM["PaymentInitiateModal - 发起刷卡"]
    PIM --> PPM["PaymentProgressModal - WebSocket 等待"]

    CPF --> PSS["PaymentSuccessSummary - 成功"]
    PPM --> PSS

    SPS -->|"按服务"| SSV["ServiceSplitView"]
    SPS -->|"按人"| PSV["PersonSplitView"]
    SPS -->|"手动金额"| MSI["ManualSplitInput"]

    style NCF fill:#2196F3,stroke:#1565C0,color:#fff
    style PSS fill:#4CAF50,stroke:#2E7D32,color:#fff

支付状态生命周期

stateDiagram-v2
    [*] --> pending: 创建订单

    pending --> processing: 发起支付
    processing --> completed: 支付成功
    processing --> failed: 支付失败
    processing --> cancelled: 用户取消

    failed --> processing: 重试
    completed --> refunded: 全额退款
    completed --> partial_refunded: 部分退款

    state "Transaction Status" as ts {
        pending: PENDING
        processing: PROCESSING
        completed: COMPLETED
        failed: FAILED
        cancelled: CANCELLED
        refunded: REFUNDED
        partial_refunded: PARTIAL_REFUNDED
    }

预约支付状态 vs 交易状态

概念字段可选值说明
预约状态 appointments.status completed / pending_payment / finished / closed 服务完成后为 completed;部分付款为 pending_payment;全额付款为 finished
预约支付状态 appointments.payment_status pending / partial / paid / refunded 追踪支付进度。partial = 部分付款(拆分中间态)
交易状态 transactions.payment_status pending / processing / completed / failed / cancelled / refunded / partial_refunded 单笔支付交易的状态
发票状态 invoices.status unpaid / partial / paid / void 发票的收款状态
跨模块链接: 预约进入 completed/pending_paymentCUJ-B4 预约状态 | 支付完成后 closedCUJ-C 日结 | 退款 → 预约回退为 pending_payment/completedCUJ-B4

关键业务规则

税率: 固定 8.875%(纽约州),应用于折扣后的 subtotal
小费: 可选,按预设比例(15%/18%/20%/25%)或自定义金额
折扣不可逆: 支付后无法撤销已应用的折扣/优惠券/礼品卡
退款不恢复折扣: 退款金额基于实际支付,不恢复已使用的优惠券或礼品卡余额
幂等保护: 结账接口支持 idempotency_key,防止网络重试造成重复扣款
礼品卡定位: 礼品卡作为"折扣"应用(扣减 subtotal),不是独立的支付方式

CUJ-F1: 结账收款 — 现金

P0 核心营收路径 — 现金收款

用户流程

flowchart TD
    A["预约状态: pending_payment"] --> B["点击 Take Payment"]
    B --> C["/checkout/[appointmentId]"]
    C --> NCF["NewCheckoutFlow 加载订单"]
    NCF --> D["OrderDetailsSection 显示服务明细"]
    D --> E["TipSelector 选择小费"]
    E --> F["PaymentMethodSelector 选择 Cash"]
    F --> G["CashPaymentForm"]
    G --> H["输入收到金额"]
    H --> I{"金额 >= 应付?"}
    I -->|"是"| J["显示找零金额"]
    I -->|"否"| K["按钮禁用,提示不足"]
    J --> L["点击确认"]
    L --> M["POST /api/payment/cash"]
    M --> N["PaymentSuccessSummary"]
    N --> O["appointments.status = finished"]

    style C fill:#2196F3,stroke:#1565C0,color:#fff
    style N fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 F1.1: 现金支付 — Happy Path

Given 预约状态为 pending_payment,服务总额 $65.00 + 税 $5.77 = $70.77
When 前台在预约看板点击 "Take Payment"
Then 跳转到 /checkout/[appointmentId]
  And NewCheckoutFlow 加载并显示 OrderDetailsSection(服务项、小计、税、总计)
When 选择 15% 小费($10.62)
  And PaymentMethodSelector 选择 "Cash"
  And CashPaymentForm 输入收到 $100.00
Then 显示找零: $18.61
When 点击 "Confirm Payment"
Then 调用 POST /api/payment/cash
  And 显示 PaymentSuccessSummary(金额、支付方式、交易号)
  And 预约 payment_status 变为 paid,status 变为 finished
  And 发票自动生成,状态为 paid

测试: checkout-flow.spec.ts, full-flow/05-payment-pairwise.spec.ts (部分覆盖)

场景 F1.2: 现金不足

Given 应付金额 $70.77
When CashPaymentForm 输入收到 $50.00
Then "Confirm Payment" 按钮保持禁用
  And 显示差额提示

场景 F1.3: 无小费直接支付

Given 预约 pending_payment,总额 $70.77
When TipSelector 选择 "No Tip"
  And 选择 Cash 并输入 $80
Then 找零 $9.23,支付完成
  And 交易记录 tip_amount = 0

CUJ-F2: 结账收款 — 刷卡

P0 核心营收路径 — POS 终端刷卡

用户流程

flowchart TD
    A["PaymentMethodSelector 选择 Card"] --> B["TerminalSelector 选择在线终端"]
    B --> C["PaymentInitiateModal 确认金额"]
    C --> D["POST /api/payment/initiate"]
    D --> E["PaymentProgressModal"]
    E --> F{"WebSocket 事件"}
    F -->|"acknowledged"| G["终端已收到请求"]
    F -->|"processing"| H["客户正在操作"]
    F -->|"completed"| I["PaymentSuccessSummary"]
    F -->|"failed"| J["显示失败原因,可重试"]
    F -->|"timeout"| K["超时,提示检查终端"]
    F -->|"cancelled"| L["用户取消"]

    style E fill:#FF9800,stroke:#E65100,color:#fff
    style I fill:#4CAF50,stroke:#2E7D32,color:#fff
    style J fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 F2.1: 信用卡支付 — Happy Path

Given 预约 pending_payment,应付 $81.39(含税含小费)
  And 至少一台 CodePay 终端在线
When PaymentMethodSelector 选择 "Card"
  And TerminalSelector 选择在线终端 "Front Desk POS"
  And PaymentInitiateModal 点击 "Send to Terminal"
Then POST /api/payment/initiate 发送支付请求到 CodePay
  And PaymentProgressModal 显示实时状态(WebSocket)
When 终端返回 payment:completed 事件
Then 显示 PaymentSuccessSummary
  And 预约 payment_status = paid,status = finished

测试: payment/processing.spec.ts (部分覆盖)

场景 F2.2: 刷卡失败后重试

Given PaymentProgressModal 正在等待
When 终端返回 payment:failed(余额不足/卡被拒)
Then 显示失败原因
  And 提供 "Retry" 按钮
When 点击 "Retry"
Then 重新发起 POST /api/payment/initiate

场景 F2.3: 无可用终端

Given 所有 POS 终端离线
When PaymentMethodSelector 选择 "Card"
Then TerminalSelector 显示 "No terminals available"
  And 提示用户检查终端连接或使用现金

场景 F2.4: 支付超时

Given PaymentProgressModal 正在等待
When 超过设定时间未收到终端响应
Then 显示 payment:timeout 提示
  And 提供 "Retry" 和 "Cancel" 选项

CUJ-F3: 折扣与礼品卡抵扣

P1 营销转化 — 在结账时应用优惠

用户流程

flowchart TD
    A["OrderDetailsSection 显示原价"] --> B["DiscountSection"]
    B --> C{"折扣类型"}
    C -->|"自动折扣"| D["系统自动应用匹配的折扣"]
    C -->|"优惠券"| E["输入优惠码"]
    C -->|"礼品卡"| F["GiftCardInput 输入卡号"]

    E --> G["POST validate coupon"]
    G --> H{"验证结果"}
    H -->|"有效"| I["应用折扣,更新金额"]
    H -->|"无效/过期"| J["显示错误"]

    F --> K["POST /api/payment/gift-card/validate"]
    K --> L{"验证结果"}
    L -->|"有效"| M["显示余额,应用抵扣"]
    L -->|"无效"| N["显示错误"]

    D --> O["AmountDueDisplay 更新应付金额"]
    I --> O
    M --> O

    style B fill:#FF9800,stroke:#E65100,color:#fff
    style O fill:#4CAF50,stroke:#2E7D32,color:#fff
不可逆警告: 折扣一旦在支付中应用即为最终结果。退款时不会恢复已使用的优惠券或礼品卡余额。UI 应在应用折扣前提示用户确认。

BDD 场景

场景 F3.1: 优惠券码应用

Given 结账页面,原价 $100.00 + 税 $8.88 = $108.88
When 在 DiscountSection 输入优惠码 "SAVE20"(20% off)
  And 点击 "Apply"
Then subtotal 变为 $80.00
  And 税重新计算为 $7.10
  And 总计更新为 $87.10

测试: full-flow/02f-discount-crud.spec.ts (部分覆盖)

场景 F3.2: 礼品卡抵扣

Given 结账总额 $108.88
When 在 GiftCardInput 输入礼品卡号
  And 礼品卡余额为 $50.00
Then 显示礼品卡余额和本次抵扣金额
When 点击 "Apply"
Then 应付金额变为 $58.88($108.88 - $50.00)
  And 礼品卡剩余余额 $0.00

测试: full-flow/02g-giftcard-crud.spec.ts (部分覆盖)

场景 F3.3: 礼品卡余额大于应付

Given 结账总额 $30.00,礼品卡余额 $50.00
When 应用礼品卡
Then 抵扣 $30.00,应付变为 $0.00
  And 礼品卡剩余余额 $20.00
  And 无需选择其他支付方式,直接完成

场景 F3.4: 无效优惠码

Given 结账页面
When 输入无效/过期优惠码 "EXPIRED2024"
Then 显示错误提示(无效/已过期/不适用)
  And 金额不变

场景 F3.5: 自动折扣

Given 系统配置了"满$100减$10"自动折扣
  And 结账 subtotal 为 $120.00
When NewCheckoutFlow 加载订单
Then DiscountSection 自动显示已应用的 $10 折扣
  And subtotal 变为 $110.00

CUJ-F4: 拆分支付

P1 支付灵活性 — 多种支付方式或分次支付

拆分模式

模式组件说明适用场景
Full(默认) 一次付清全部金额 普通结账
By Service ServiceSplitView.tsx 选择本次要付哪些服务项 客户只想先付部分服务
By Person PersonSplitView.tsx 按客人分别付款 团体预约各付各的
Manual Amount ManualSplitInput.tsx 手动输入本次支付金额 不规则拆分

用户流程

flowchart TD
    A["SplitPaymentSelector 选择拆分模式"] --> B{"模式"}
    B -->|"By Service"| C["ServiceSplitView 勾选服务"]
    B -->|"By Person"| D["PersonSplitView 选择客人"]
    B -->|"Manual"| E["ManualSplitInput 输入金额"]

    C --> F["AmountDueDisplay 显示本次应付"]
    D --> F
    E --> F

    F --> G["选择支付方式并完成"]
    G --> H{"全部付清?"}
    H -->|"是"| I["payment_status = paid"]
    H -->|"否"| J["payment_status = partial"]
    J --> K["PaidTransactionsList 显示已付记录"]
    K --> L["继续下一笔支付"]
    L --> A

    style F fill:#FF9800,stroke:#E65100,color:#fff
    style I fill:#4CAF50,stroke:#2E7D32,color:#fff
    style J fill:#FFD54F,stroke:#F9A825,color:#333

BDD 场景

场景 F4.1: 现金+刷卡拆分

Given 结账总额 $100.00
When SplitPaymentSelector 选择 "Manual Amount"
  And ManualSplitInput 输入 $40.00
  And 选择 Cash 支付 $40.00
Then 第一笔交易完成
  And PaidTransactionsList 显示: Cash $40.00
  And 剩余应付 $60.00
When 选择 Card 支付剩余 $60.00
  And 刷卡成功
Then 全部付清,payment_status = paid

测试: full-flow/05-payment-pairwise.spec.ts (部分覆盖)

场景 F4.2: 按服务拆分

Given 预约包含: Gel Manicure $45 + Pedicure $55 = $100
When SplitPaymentSelector 选择 "By Service"
  And ServiceSplitView 勾选 "Gel Manicure"
  And 本次支付 $45(含税)
Then 支付完成后 payment_status = partial
  And 发票状态为 partial
  And 可继续支付剩余 $55

CUJ-F5: 团体结账

P1 大额订单 — 多客人预约的结账

结账模式

模式说明发票类型
Total(总付) 一人付全部客人的费用 group invoice,payment_mode = total
Individual(分人付) 每位客人分别付自己的费用 group invoice,payment_mode = individual

BDD 场景

场景 F5.1: 团体总付

Given 团体预约: Alice $60 + Bob $45 = $105(含税 $114.31)
When 结账选择 payment_mode = "Total"
  And Alice 的信用卡支付全部 $114.31
Then 生成 group invoice,所有 invoice_items 状态为 paid
  And 预约 payment_status = paid

场景 F5.2: 团体分人付

Given 团体预约: Alice $60 + Bob $45
When 结账选择 payment_mode = "Individual"
  And PersonSplitView 选择 Alice
  And Alice 刷卡支付 $65.33(含税)
Then Alice 的 invoice_item status = paid
  And 预约 payment_status = partial
When Bob 现金支付 $48.98(含税)
Then Bob 的 invoice_item status = paid
  And 预约 payment_status = paid

场景 F5.3: 跳过某位客人的付款

Given 团体预约: Alice $60 + Bob $45,Individual 模式
When Alice 支付完成
  And 对 Bob 点击 "Skip"(POST /api/invoice-items/:id/skip
Then Bob 的 invoice_item status = skipped
  And 预约可以完成结账(status = finished

测试: 缺失 — 需新增

CUJ-F6: 退款

P1 纠纷处理 — 全额或部分退款

退款路径

退款类型适用条件到账时间金额限制API
VOID(作废) 当天的刷卡交易 即时 仅全额 POST /api/payment/refunds/card
Card Refund 非当天刷卡交易 3-5 个工作日 全额或部分 POST /api/payment/refunds/card
Cash Refund 原支付为现金 即时 全额或部分 POST /api/payment/refunds/cash
Gift Card Refund 原支付含礼品卡 即时(余额恢复) 全额或部分 POST /api/payment/refunds/gift-card

用户流程

flowchart TD
    A["已完成支付的预约"] --> B["打开 RefundModal"]
    B --> C["显示原交易信息"]
    C --> D{"退款类型"}
    D -->|"全额"| E["自动填入全部金额"]
    D -->|"部分"| F["手动输入退款金额"]

    E --> G["选择退款原因"]
    F --> G
    G --> H{"原支付方式"}
    H -->|"Card 当天"| I["VOID 到同一终端"]
    H -->|"Card 非当天"| J["Standard Refund"]
    H -->|"Cash"| K["现金退款(关联钱箱)"]
    H -->|"Gift Card"| L["余额恢复"]

    I --> M{"处理结果"}
    J --> M
    K --> M
    L --> M
    M -->|"成功"| N["更新 transaction.refunded_amount"]
    M -->|"失败"| O["显示错误,可重试"]

    N --> P["更新 appointment.payment_status"]

    style B fill:#f44336,stroke:#c62828,color:#fff
    style N fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 F6.1: 当天刷卡 VOID

Given 今天的刷卡交易 $75.00,transaction status = completed
When 打开 RefundModal
  And 系统检测为当天交易,自动选择 "VOID"
  And 输入原因 "客户要求取消"
  And 选择同一 POS 终端
  And 确认
Then VOID 请求发送到 CodePay 终端
  And 交易状态变为 refunded
  And 预约 payment_status 变为 refunded,status 变为 completed

测试: 缺失 — 需新增

场景 F6.2: 部分退款(信用卡)

Given 非当天的刷卡交易 $100.00
When RefundModal 输入退款金额 $30.00
  And 确认
Then 退款 $30.00 处理中(3-5个工作日到账)
  And transaction.refunded_amount = 30
  And 预约 payment_status 保持 paid(部分退款不改变已付状态)

场景 F6.3: 现金退款

Given 现金交易 $50.00,钱箱 session 已打开
When RefundModal 选择全额退款
Then 现金退款 $50.00
  And 关联当前 cash_drawer_session
  And 钱箱现金余额相应减少

场景 F6.4: 退款失败重试

Given 退款请求失败(网络/终端问题)
When 点击 "Retry"(POST /api/payment/refunds/:refundId/retry
Then 重新发起退款请求

CUJ-F7: 发票管理

P2 财务记录 — 查看和打印发票

发票数据结构

字段说明
invoice_number人类可读的发票号
invoice_typesingle(单人)/ group(团体)
payment_modetotal(总付)/ individual(分人付)
total_amount发票总额
paid_amount已付金额
statusunpaid / partial / paid / void

BDD 场景

场景 F7.1: 查看发票详情

Given 结账完成后
When 导航到 /invoices/[id]
Then 显示: 发票号、客人信息、服务项目、各项金额
  And 显示: 小计、折扣、税(8.875%)、小费、总计
  And 显示: 支付方式、交易时间
  And 提供 "Print" 和 "Email" 按钮

测试: invoices/management.spec.ts, full-flow/09-invoice.spec.ts (部分覆盖)

场景 F7.2: 团体发票 — 多 invoice_items

Given 团体预约已结账,invoice_type = group
When 查看发票
Then 按客人分组显示各自的服务项和金额
  And 每个 invoice_item 显示独立的 status(paid/skipped)
  And 底部汇总全部金额

场景 F7.3: 作废发票

Given 已生成的发票
When 用户执行 "Void Invoice"(DELETE /api/invoices/:id
Then 发票状态变为 void
  And 关联交易不受影响(退款需单独处理)

CUJ-F8: 终端管理

P2 硬件设施 — 管理 CodePay POS 终端

BDD 场景

场景 F8.1: 添加新终端

Given 用户已登录并拥有对应功能所需权限与范围(scope),在支付设置页面
When 点击 "Add Terminal" 打开 AddTerminalModal
  And 输入终端名称和序列号
  And 确认
Then 终端注册到 CodePay
  And 出现在 TerminalSelector 列表中

测试: 缺失 — 需新增

场景 F8.2: 查看终端状态

Given 已注册的 POS 终端列表
When 查询 GET /api/payment/terminals
Then 显示每台终端的名称、序列号、在线/离线状态
  And 在线终端可用于 TerminalSelector