---
description: "一屏交清「你在花什么钱」：顶部显示当前套餐与续费日期，中间三档并排让人一眼看清升降级，底部近几期发票带下载。SaaS 后台的收款出口都长这个形状，展示层跟真实计费服务商解耦 —— 先把 UI 骨架搭对，再把按钮接到 Stripe / 微信支付 / … 的 Checkout"
---

# 计费（订阅套餐 + 发票）

一屏交清「你在花什么钱」：顶部显示当前套餐与续费日期，中间三档并排让人一眼看清升降级，底部近几期发票带下载。SaaS 后台的收款出口都长这个形状，展示层跟真实计费服务商解耦 —— 先把 UI 骨架搭对，再把按钮接到 Stripe / 微信支付 / … 的 Checkout。

<iframe src="/skills/dashboard/assets/demo-billing.html"
        style="width:100%;height:700px;border:1px solid #8884;border-radius:10px"
        loading="lazy" title="形状 demo：计费"></iframe>

试这几下：

- 点**升级到企业版** —— 蓝色主按钮确认框；点**降级到个人版** —— 红色危险按钮确认框，并列出会失去的特性
- 确认后当前套餐卡片和三档 CTA 联动更新，toast 提示；当前档按钮固定禁用为「当前套餐」
- 发票行的**下载**：已作废那一条禁用，其他点了发 toast

## 规矩

- **展示层吃已成型 props**：`PlanCard` 只拿 `{ plan, current, onSelect }`，价格 / 特性 / 推荐标由外部计算好，不做数据抓取。真实版由页面容器（route loader / server fn）把套餐清单和当前订阅传下来
- **当前档 CTA 禁用并写「当前套餐」**，其他档按方向给「升级到 X」/「降级到 X」；方向来自 `rank` 比较，而不是价格数字（避免促销 / 年付时价格倒挂误导按钮语义）
- **破坏性方向走 destructive 语义**：降级按钮红色边框，确认框主按钮也红色；升级走蓝色主按钮。两个方向都过一次确认 —— demo 就地 `setCurrentId`，真实版跳计费服务商的 Checkout Session（升级 / 加档）或 Billing Portal（降级 / 退订），回来后靠 webhook（`checkout.session.completed` / `customer.subscription.updated`）把最终状态写回数据库，页面只反映
- **发票下载走签名 URL**：demo 只弹 toast；真实版换成 `window.open(signedUrl)`，URL 由服务端签发后短时有效，前端不接触对象存储 / Stripe secret 凭证
- 服务端密钥（Stripe secret key、微信支付 API v3 密钥）只留在 server fn / API route，客户端可达模块里绝不出现
- 破坏性操作先过确认弹窗；任何变更成功后 toast

> 蓝本：`add-billing.md`[^add-billing]（open-dashboard @ `aa9815f`，MIT，Invariants 已消化进上面「规矩」）

??? abstract "demo 源码：`assets/demo-billing.html`（自包含、未压缩）"

    ```html
    --8<-- "skills/dashboard/assets/demo-billing.html"
    ```

[^add-billing]: [`add-billing.md`](https://github.com/ahpxex/open-dashboard/blob/aa9815f426e9520841c4aa15a341bfd9f866091f/.claude/skills/add-component/references/add-billing.md)，2026-07-28 读。
