计费生命周期:客户端状态、错误与恢复

本文是一张映射表:从 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_viaCard: {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 == nullNo 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_nowUpgrade to {target}. You will be charged {amount} now (prorated).(+ 月度额度增量,+ 解析器确知时显示哪张卡)Pay {amount} & upgrade now
scheduledChange to {target} — takes effect {date}. No charge now; you keep your current plan until then.Schedule change to {target}
no_opYou 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成功文案
cancellationScheduled — 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_revokedYour 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_limitedToo 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 URLsubscription_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),此处尚未对其做类型化——在客户端更新加入显式分支之前,它们会以未知码到达并降级到这条默认路径。