计费生命周期:客户端状态、错误与恢复
本文是一张映射表:从 gateway(来自 NAS)下发的每一种 billing.*/subscription.* 状态形态,映射到终端实际渲染的内容;并从每一个带类型的拒绝/错误码,映射到其确切的用户可见文案与恢复动作。我们的保证是:任何 NAS 计费状态、任何带类型的拒绝,都不会落到一条通用 toast 上——下面每一种情况都是 ui-tui/src/app/slash/commands/topup.ts、ui-tui/src/components/billingOverlay.tsx 或 ui-tui/src/components/subscriptionOverlay.tsx 中的一条显式分支。未知码仍会优雅降级:它命中 default 分支(一条取自服务端 payload 的通用但真实的消息,绝不是空 toast),而不是崩溃或静默丢弃该拒绝。
1. billing.state 形态 → 渲染
来源:ui-tui/src/components/billingOverlay.tsx(OverviewScreen、BuyScreen、AutoReloadScreen),ui-tui/src/app/slash/commands/topup.ts(/topup 的运行)。
| 状态形态 | 渲染 |
|---|---|
未登录(s.logged_in === false) | 覆盖层永不打开。sys:💳 Not logged into Nous Portal — run /portal to log in, then /topup. |
billing.state RPC 拉取失败(传输/超时) | 失败关闭:.catch(ctx.guardedErr)——覆盖层永不打开,不臆测任何状态。sys:error: <message 或 "request failed">。绝不渲染"无卡"或任何其他猜测状态;用户必须重试 /topup。 |
card: null(无已存卡),完整菜单(is_admin && cli_billing_enabled) | 概览显示 No saved card on file — "Add funds" walks you through adding one.。"Add funds" 打开添加卡路径:Add a card on the portal / I've added it — check again / Back(绝不出现金额选择器,否则会 403 no_payment_method)。 |
存在 card,且设置了 resolved_via | Card: {display}(例如 Visa ····4242 — the card on your subscription),使用可识别来源的 display 字段。 |
存在 card,无 resolved_via(旧版 NAS) | 回退到通用的 Card: {masked};确认屏追加 Your card saved on the portal will be charged. |
auto_reload: null | 完全不显示自动充值行(autoReloadLine 返回 null)——该功能不暴露。 |
auto_reload.card.kind: 'canonical' | 不显示"卡不同"警告;卡行回退到存档卡。 |
auto_reload.card.kind: 'distinct' | 在自动充值屏显示 ⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.(差异提示)。 |
auto_reload.card.kind: 'none' | 渲染上与 canonical 相同——不显示"卡不同"警告。 |
存在 monthly_cap,且 limit_usd != null | {spent_display} of {limit_display} used this month(仅当 is_default_ceiling 时追加 (default ceiling))。 |
无 monthly_cap 或 limit_usd == null | No monthly cap visible (managed on the portal). |
无计费权限的角色(!is_admin,菜单折叠) | 提示:Billing actions need someone with billing permissions (owner, admin, or finance admin).。菜单折叠为 Manage on portal / Cancel。 |
组织熔断开关关闭(is_admin 但 !cli_billing_enabled) | 提示:Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.。同样的折叠菜单。 |
注意:full = s.is_admin && s.cli_billing_enabled 控制的是组织级开关,而非按终端的 billing:manage scope——后者是反应式发现的(一次扣费 403 insufficient_scope),并路由到可恢复的 step-up 屏,而不是做预检。
2. 拒绝码(renderBillingError,按代码顺序)
来源:renderBillingError,位于 ui-tui/src/app/slash/commands/topup.ts:37-149。只要 portal_url 存在,每种码(包括 default)都会追加一行 "Portal" = sys('Portal: {portal_url}')。
| error 码 | 文案 | Portal URL | retry_after |
|---|:-:|:-:|
| insufficient_scope | This needs Remote Spending allowed. Start a top-up to allow it, then retry. | 若存在 | — |
| remote_spending_revoked(CF-4) | {An admin stopped remote spending for this terminal. \| You stopped remote spending for this terminal.}(按 actor 区分)Reconnect to restore — run /portal to re-authorize this terminal.。同时立即清除 billing 覆盖层状态(不等 token 刷新)。 | 若存在 | — |
| session_revoked | Your session was logged out. Run /portal to log in again.。同时清除 billing 覆盖层状态。 | 若存在 | — |
| cli_billing_disabled / remote_spending_disabled(双发) | Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page. | 若存在 | — |
| role_required | Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal. | 若存在 | — |
| consent_required | This action needs a one-time card confirmation and consent step on the portal before it can proceed. | 若存在 | — |
| org_access_denied | This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal. | 若存在 | — |
| upgrade_cap_exceeded | 🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal. | 若存在 | — |
| auto_top_up_disabled_failures | Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload. | 若存在 | — |
| idempotency_conflict | 🔴 That charge key was already used for a different amount. Start a fresh top-up. | 若存在 | — |
| no_payment_method | 💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card). | 若存在 | — |
| monthly_cap_exceeded | 若 payload.remainingUsd 存在:🔴 Monthly spend cap reached — ${remainingUsd} headroom left.,否则 🔴 Monthly spend cap reached. | 若存在 | — |
| rate_limited / temporarily_unavailable | 🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure. | 若存在 | 是——分钟数按 max(1, round(retry_after/60)) 计算 |
| stripe_unavailable | 🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}. | 若存在 | 是(同一公式) |
| default(未知/其他) | 🔴 {message \|\| error \|\| 'Billing request failed.'}——仍会展示服务端所说的任何内容,绝不是空 toast。 | 若存在 | — |
3. 扣费结算结果(pollCharge / renderChargeFailed)
来源:pollCharge(ui-tui/src/app/slash/commands/topup.ts:170-258)与 renderChargeFailed(:260-290)。轮询节奏:2 秒间隔、5 分钟上限(POLL_INTERVAL_MS=2000、POLL_CAP_MS=5*60*1000),应用于每一条非终态路径(pending 和 throttled),因此持续的 429/503 无法让轮询永远挂着。
| 结果 | 文案 | 备注 |
|---|---|---|
status: 'settled' | ✅ ${amount_usd} added.(若无金额则 ✅ Credits added.) | 终态成功。 |
status: 'failed',reason: 'authentication_required' | 🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase. | 若有 portalUrl,追加 Portal: 行。 |
status: 'failed',reason: 'payment_method_expired' | 🔴 Your card has expired. Update it on the portal. | 追加 Portal: 行。 |
status: 'failed',reason: 'card_declined' | 🔴 Your card was declined. Try another card on the portal. | 追加 Portal: 行。 |
status: 'failed',reason: 'processing_error' | 🔴 The charge didn't go through (processing_error). | 追加 Portal: 行。 |
status: 'failed',无法识别/缺失 reason | 🔴 The charge didn't go through ({reason \|\| 'processing_error'}). | 同一 portal 漏斗——与 cli.py 的 _billing_portal_hint 对齐。 |
轮询超时(超过 5 分钟上限仍 pending) | 🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly. | 若有 portalUrl 追加 Portal: 行。明确不称为失败。 |
轮询途中撤销(轮询时 remote_spending_revoked / session_revoked) | 渲染对应的 §2 文案,然后追加:🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying. | CF-7 规则 4:轮询时撤销后又 403 是含糊的(扣费可能已结算)——绝不称其为"failed"。 |
轮询时 429/503(rate_limited/temporarily_unavailable/stripe_unavailable) | 不显示错误;按 retry_after 退避(默认 5s,上限 30s)并继续轮询直到 5 分钟上限,随后读作超时。 | 不是支付失败。 |
其他 !ok 状态检查错误 | 🔴 Could not check the charge: {message \|\| error \|\| 'error'} | |
| 传输丢失(轮询 RPC 抛错/reject) | 🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.(UNCONFIRMED_CHARGE_MESSAGE) | 与轮询途中撤销相同的"未确认,请查余额"框架——断线永远不能读作"failed"。 |
4. 订阅预览 / 待处理变更 / 升级结果
来源:ui-tui/src/components/subscriptionOverlay.tsx 中的 previewAndRoute、applyPendingAndRoute、upgradeResult、stepUpDenialResult。
预览 effect 值(驱动确认屏):
effect | 确认屏文案 | 主操作 |
|---|---|---|
charge_now | Upgrade to {target}. You will be charged {amount} now (prorated).(+ 月度额度增量,+ 解析器确知时显示哪张卡) | Pay {amount} & upgrade now |
scheduled | Change to {target} — takes effect {date}. No charge now; you keep your current plan until then. | Schedule change to {target} |
no_op | You are already on {target} — nothing to change. | 无(仅 Back) |
blocked | {preview.reason} 或回退 That change cannot be made here — manage it on the portal. | Manage on portal |
预览 RPC 返回 null/传输失败 | 直接路由到结果屏:Could not preview that change. | — |
预览 !ok,insufficient_scope | 路由到 stepup 屏({kind:'preview', tierId}) | — |
预览 !ok,其他错误 | 以 errorResult(p) 路由到结果屏(message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.') | — |
待处理变更应用结果(applyPendingAndRoute):
pending.kind | 成功文案 |
|---|---|
cancellation | Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today. |
tier_change(降级/排期) | Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches. |
upgrade | 经 upgradeResult 路由(见下) |
任意 kind,变更时 insufficient_scope | 路由到 stepup({kind:'apply'}) |
升级 status × reason 矩阵(upgradeResult,按此顺序检查——reason 先于 status 检查):
| 条件 | 结果 |
|---|---|
r === null(扣费路由上的传输失败) | Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.——含糊,绝不盲目重试。 |
reason: 'authentication_required' 或 reason: 'subscription_payment_intent_requires_action' | Please verify your card in the portal to finish this upgrade. → recovery_url。两个 reason 映射到同一条 SCA 文案——客户端按 reason 而非 status 分支,正是为了让一个被 pre-#711 NAS 误标为 status: 'payment_failed'(尚无区分性 reason)的 SCA 案例,仍路由到正确的"verify your card"文案,而不是读作硬性拒付。 |
reason: 'card_declined' | Your card was declined — try a different card on the portal. → recovery_url。 |
ok && status: 'already_on_tier' | You are already on {target_tier_name}.(成功) |
ok && status: 'upgraded' | Upgraded to {target_tier_name}. Your new monthly credits land in a moment.——启动最终一致性 apply 轮询(见下)。 |
status: 'requires_action'(无区分性 reason) | This upgrade needs extra verification (3DS). Finish it on the portal. → recovery_url。 |
status: 'payment_failed'(无区分性 reason) | Your card was declined. Update your payment method on the portal and try again. → recovery_url。 |
| 其他一切 | errorResult(r):message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.' |
最终一致性 apply 轮询(ResultScreen,仅在 status: 'upgraded' 之后):每 2 秒轮询 billing/订阅状态(UPGRADE_CONFIRM_INTERVAL_MS),最多 15 次(UPGRADE_CONFIRM_ATTEMPTS,约 30s),直到 current.tier_id 翻到目标档。等待期间屏显 Applying…;若在预算内始终未翻转,则读作 Still applying / Your upgrade succeeded and is still applying — refresh in a moment.——绝不只是因为 NAS 还没追上就把升级重新报为失败。
Step-up 拒绝文案(stepUpDenialResult,订阅流程):
error | 文案 |
|---|---|
session_revoked | Your session expired — run /portal to log in again, then retry the change. |
remote_spending_revoked | {message} 或 Remote spending was stopped for this terminal — reconnect from the portal, then retry. |
rate_limited | Too many attempts — wait a moment, then try again. |
| 其他/未知 | {message} 或 Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal. |
在授权后重放期间重复的 scope 拒绝绝不会再次进入 step-up 屏(它已经挂在那里了——重新打补丁会冻结它);allowStepUp=false 改为渲染一条终态结果:Remote Spending still isn’t active for this terminal — the authorization didn’t take. Retry, or make this change on the portal.
文本模式(CLI)对齐
cli.py 的 _show_billing / _billing_overview 与 _show_subscription / _subscription_overview 渲染相同的状态形态(余额标题、双条美元用量、自动充值行、卡行、月度上限),并共享"在未登录/portal 抖动时 fail-open、绝不崩溃"的纪律。CLI 的 /subscription 在交互上下文中为已付费的 admin/owner 提供完整的终端内变更流程(档位选择 → 预览 → 确认 → 应用,与 TUI 覆盖层对齐);成员与非交互上下文回退到 _billing_portal_hint 指向 subscription_manage_url 的深链。/topup 的交互模态(prompt_toolkit)以同样方式镜像 TUI 覆盖层,非交互上下文回退到同样的文本 + portal 链接渲染,绝不弹出提示。
| CLI 界面 / 状态 | 行为(与 TUI / 桌面对齐) |
|---|---|
Free 档 + admin/owner + 交互下的 /subscription | _subscription_free_catalog 打印与 TUI 使用的同一份 tiers[] 数据构成的档位目录——每个已启用付费档一行,最便宜在前,格式 name · $/mo · $credits/mo(月度额度是美元 → $22 credits/mo,绝不是裸数字)。输入编号选择会打开 /manage-subscription 深链并追加 plan=<tier_id>,让 portal 预选所选档位。新订阅需要新卡,因此唯一动作是转交 portal(终端在此绝不扣费)。 |
| 任意 CLI 构造的 manage/subscribe URL | subscription_manage_url(state, tier_id=…) 仅当选了某档位时(Free 目录)追加 plan=<tier_id>(稳定的 tiers[] id,绝不是名称/slug)。portal 在服务端校验并忽略未知档位,因此 CLI 在选中时无条件追加,镜像 TUI 的 ?plan=。org_id 在前,plan 在后。 |
| CLI 中的降级 | 对普通变更保持原生 / 应用内(经 put_subscription_pending_change 做免费排期)。被阻止的降级仍可能打印通用 manage URL,但绝不带 plan=<tier_id>——选中档深链保留给新订阅与升级。 |
/topup 概览动作文案 | 把一次性充值与自动补充分开,区别在每句首句就点明:Add funds now — a single charge, added to your balance today. 对比 Refill when low — charges $X automatically when your balance falls below $Y.("credits" 不进纯美元的 /topup 界面——"Add funds now" 不带它也表达了一次性含义)。自动充值关闭时,自动行省略具体金额。 |
前向兼容
任何不在上表中的 error/status/reason 码都会落到 renderBillingError(§2)的 default 分支,或 errorResult/upgradeResult 的 fallthrough(§4):它仍会渲染服务端自己的 message(绝不空白、绝不崩溃),只是没有定制文案或带类型的恢复动作。NAS W3 引入了卡健康码(card_paused、card_expired、card_mismatch),此处尚未对其做类型化——在客户端更新加入显式分支之前,它们会以未知码到达并降级到这条默认路径。